From 9c41b172e81f94c8d511769dff1f570a7cb11de9 Mon Sep 17 00:00:00 2001 From: bkaradzic-microsoft Date: Wed, 15 Jul 2026 09:00:47 -0700 Subject: [PATCH 01/10] Add native C++ Draco decoder (NativeDraco plugin) to replace WASM 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 --- CMakeLists.txt | 4 + Dependencies/CMakeLists.txt | 39 ++++ Embedding/CMakeLists.txt | 4 + Embedding/Source/Runtime.cpp | 2 + Plugins/CMakeLists.txt | 2 + Plugins/NativeDraco/CMakeLists.txt | 17 ++ .../Include/Babylon/Plugins/NativeDraco.h | 12 ++ Plugins/NativeDraco/Source/NativeDraco.cpp | 201 ++++++++++++++++++ 8 files changed, 281 insertions(+) create mode 100644 Plugins/NativeDraco/CMakeLists.txt create mode 100644 Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h create mode 100644 Plugins/NativeDraco/Source/NativeDraco.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index cb1ce4b05..54a19d0f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,10 @@ FetchContent_Declare(glslang GIT_REPOSITORY https://github.com/BabylonJS/glslang.git GIT_TAG 284e4301e5a6b44b279635276588db7cdd942624 EXCLUDE_FROM_ALL) +FetchContent_Declare(draco + GIT_REPOSITORY https://github.com/google/draco.git + GIT_TAG 1.5.7 + EXCLUDE_FROM_ALL) FetchContent_Declare(googletest URL "https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz" EXCLUDE_FROM_ALL) diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 5a41c1853..2e01531c1 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -167,6 +167,45 @@ if(NOT TARGET glslang) set_property(TARGET SPIRV PROPERTY FOLDER Dependencies/glslang) endif() +# -------------------------------------------------- +# draco (mesh decompression, native replacement for the WASM decoder) +# -------------------------------------------------- +if(NOT TARGET draco AND NOT TARGET draco_static) + set(DRACO_TESTS OFF CACHE BOOL "" FORCE) + set(DRACO_INSTALL OFF CACHE BOOL "" FORCE) + set(DRACO_BUILD_EXECUTABLES OFF CACHE BOOL "" FORCE) + set(DRACO_JS_GLUE OFF CACHE BOOL "" FORCE) + + FetchContent_MakeAvailable_With_Message(draco) + + # The combined draco library target is named `draco` for MSVC and + # `draco_static` for other toolchains (static build); `draco::draco` aliases + # whichever exists. + if(TARGET draco) + set(_draco_lib draco) + else() + set(_draco_lib draco_static) + endif() + + # draco adds its include paths as PRIVATE (see cmake/draco_targets.cmake + # draco_add_library), so linking draco::draco does not expose the draco/* + # headers to consumers. Re-export them as PUBLIC on the combined target. + # The generated draco/draco_features.h is emitted under CMAKE_BINARY_DIR + # (draco uses ${CMAKE_BINARY_DIR} as its generated-sources root). + target_include_directories(${_draco_lib} SYSTEM PUBLIC + "${draco_SOURCE_DIR}" + "${draco_SOURCE_DIR}/src" + "${draco_BINARY_DIR}" + "${CMAKE_BINARY_DIR}") + + # Assign IDE folders for whichever draco targets exist. + foreach(_draco_target IN ITEMS draco draco_static) + if(TARGET ${_draco_target}) + set_property(TARGET ${_draco_target} PROPERTY FOLDER Dependencies/draco) + endif() + endforeach() +endif() + # -------------------------------------------------- # googletest # -------------------------------------------------- diff --git a/Embedding/CMakeLists.txt b/Embedding/CMakeLists.txt index 20715f0bb..35679bf47 100644 --- a/Embedding/CMakeLists.txt +++ b/Embedding/CMakeLists.txt @@ -107,6 +107,10 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEENCODING) target_link_libraries(Embedding PRIVATE NativeEncoding) endif() +# NativeDraco is always built: it provides the native replacement for the WASM +# Draco decoder that Babylon.js routes to when present. +target_link_libraries(Embedding PRIVATE NativeDraco) + if(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS) target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS=1) target_link_libraries(Embedding PRIVATE NativeOptimizations) diff --git a/Embedding/Source/Runtime.cpp b/Embedding/Source/Runtime.cpp index 852528288..8d3afa54f 100644 --- a/Embedding/Source/Runtime.cpp +++ b/Embedding/Source/Runtime.cpp @@ -5,6 +5,7 @@ #if BABYLON_NATIVE_PLUGIN_NATIVEENGINE #include #endif +#include #if BABYLON_NATIVE_PLUGIN_NATIVECAMERA #include #endif @@ -270,6 +271,7 @@ namespace Babylon::Embedding #if BABYLON_NATIVE_PLUGIN_NATIVEENGINE Babylon::Plugins::NativeEngine::Initialize(env); #endif + Babylon::Plugins::NativeDraco::Initialize(env); #if BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS Babylon::Plugins::NativeOptimizations::Initialize(env); #endif diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 38b7aaa18..f99c61e94 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -18,6 +18,8 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE) add_subdirectory(NativeEngine) endif() +add_subdirectory(NativeDraco) + if(BABYLON_NATIVE_PLUGIN_NATIVEINPUT) add_subdirectory(NativeInput) endif() diff --git a/Plugins/NativeDraco/CMakeLists.txt b/Plugins/NativeDraco/CMakeLists.txt new file mode 100644 index 000000000..2a596b74d --- /dev/null +++ b/Plugins/NativeDraco/CMakeLists.txt @@ -0,0 +1,17 @@ +set(SOURCES + "Include/Babylon/Plugins/NativeDraco.h" + "Source/NativeDraco.cpp") + +add_library(NativeDraco ${SOURCES}) +warnings_as_errors(NativeDraco) + +target_include_directories(NativeDraco + PUBLIC "Include") + +target_link_libraries(NativeDraco + PUBLIC napi + PRIVATE JsRuntimeInternal + PRIVATE draco::draco) + +set_property(TARGET NativeDraco PROPERTY FOLDER Plugins) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h b/Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h new file mode 100644 index 000000000..6f56e1b94 --- /dev/null +++ b/Plugins/NativeDraco/Include/Babylon/Plugins/NativeDraco.h @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace Babylon::Plugins::NativeDraco +{ + // Exposes `_native.decodeDracoMesh(dataView, attributes?)`, a synchronous + // native replacement for Babylon's WebAssembly Draco decoder. Babylon.js + // routes its DracoDecoder to this function when it is present. + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp new file mode 100644 index 000000000..3f63e5493 --- /dev/null +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -0,0 +1,201 @@ +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace Babylon::Plugins +{ + namespace + { + // De-interleaves and tightly packs one Draco attribute's per-point values into a + // freshly allocated JS typed array of type T. This mirrors emscripten's + // GetAttributeDataArrayForAllPoints, which Babylon's WASM decoder relies on. + template + Napi::Value CopyAttributeData(Napi::Env env, const draco::PointCloud& pointCloud, const draco::PointAttribute& attribute) + { + const int8_t numComponents = attribute.num_components(); + const uint32_t numPoints = pointCloud.num_points(); + const size_t numValues = static_cast(numPoints) * numComponents; + + auto array = Napi::TypedArrayOf::New(env, numValues); + T* out = array.Data(); + + for (draco::PointIndex i(0); i < numPoints; ++i) + { + const draco::AttributeValueIndex valueIndex = attribute.mapped_index(i); + attribute.ConvertValue(valueIndex, numComponents, out + static_cast(i.value()) * numComponents); + } + + return array; + } + + // Builds the { kind, data, size, byteOffset, byteStride, normalized } record that + // Babylon's DracoDecoder consumes for each decoded vertex attribute. + Napi::Value DecodeAttribute(Napi::Env env, const draco::PointCloud& pointCloud, const draco::PointAttribute& attribute, const std::string& kind) + { + const int8_t numComponents = attribute.num_components(); + + Napi::Value data; + uint32_t bytesPerComponent = 0; + switch (attribute.data_type()) + { + case draco::DT_FLOAT32: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 4; break; + case draco::DT_INT8: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 1; break; + case draco::DT_UINT8: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 1; break; + case draco::DT_INT16: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 2; break; + case draco::DT_UINT16: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 2; break; + case draco::DT_INT32: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 4; break; + case draco::DT_UINT32: data = CopyAttributeData(env, pointCloud, attribute); bytesPerComponent = 4; break; + default: + throw Napi::Error::New(env, "Draco: Cannot decode invalid attribute data type " + std::to_string(attribute.data_type())); + } + + auto result = Napi::Object::New(env); + result.Set("kind", Napi::String::New(env, kind)); + result.Set("data", data); + result.Set("size", Napi::Number::New(env, numComponents)); + // GetAttributeDataArrayForAllPoints returns a tightly packed array, so the + // consumable buffer has offset 0 and a stride of one full vertex. + result.Set("byteOffset", Napi::Number::New(env, 0)); + result.Set("byteStride", Napi::Number::New(env, static_cast(numComponents) * bytesPerComponent)); + result.Set("normalized", Napi::Boolean::New(env, attribute.normalized())); + return result; + } + + Napi::Value DecodeDracoMesh(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + + if (info.Length() < 1 || !info[0].IsTypedArray()) + { + throw Napi::TypeError::New(env, "decodeDracoMesh: expected a typed array of compressed Draco data"); + } + + const auto typedArray = info[0].As(); + const auto* data = static_cast(typedArray.ArrayBuffer().Data()) + typedArray.ByteOffset(); + const size_t size = typedArray.ByteLength(); + + draco::DecoderBuffer buffer; + buffer.Init(data, size); + + draco::Decoder decoder; + const auto geometryTypeStatus = draco::Decoder::GetEncodedGeometryType(&buffer); + if (!geometryTypeStatus.ok()) + { + throw Napi::Error::New(env, geometryTypeStatus.status().error_msg()); + } + + std::unique_ptr geometry; + Napi::Value indices = env.Null(); + + switch (geometryTypeStatus.value()) + { + case draco::TRIANGULAR_MESH: + { + auto meshStatus = decoder.DecodeMeshFromBuffer(&buffer); + if (!meshStatus.ok()) + { + throw Napi::Error::New(env, meshStatus.status().error_msg()); + } + + std::unique_ptr mesh = std::move(meshStatus).value(); + + const uint32_t numFaces = mesh->num_faces(); + auto indicesArray = Napi::Uint32Array::New(env, static_cast(numFaces) * 3); + uint32_t* out = indicesArray.Data(); + for (uint32_t f = 0; f < numFaces; ++f) + { + const draco::Mesh::Face& face = mesh->face(draco::FaceIndex(f)); + out[f * 3 + 0] = face[0].value(); + out[f * 3 + 1] = face[1].value(); + out[f * 3 + 2] = face[2].value(); + } + indices = indicesArray; + + geometry = std::move(mesh); + break; + } + case draco::POINT_CLOUD: + { + auto pointCloudStatus = decoder.DecodePointCloudFromBuffer(&buffer); + if (!pointCloudStatus.ok()) + { + throw Napi::Error::New(env, pointCloudStatus.status().error_msg()); + } + geometry = std::move(pointCloudStatus).value(); + break; + } + default: + throw Napi::Error::New(env, "Draco: Cannot decode invalid geometry type"); + } + + auto attributes = Napi::Array::New(env); + uint32_t attributeCount = 0; + + if (info.Length() > 1 && info[1].IsObject()) + { + // glTF path: caller provides a map of Babylon vertex-buffer kind -> Draco unique id. + const auto attributeIds = info[1].As(); + const auto keys = attributeIds.GetPropertyNames(); + for (uint32_t i = 0; i < keys.Length(); ++i) + { + const auto kind = keys.Get(i).As().Utf8Value(); + const uint32_t id = attributeIds.Get(kind).As().Uint32Value(); + const draco::PointAttribute* attribute = geometry->GetAttributeByUniqueId(id); + if (attribute != nullptr) + { + attributes.Set(attributeCount++, DecodeAttribute(env, *geometry, *attribute, kind)); + } + } + } + else + { + // Standalone path: probe the standard named attributes. + const struct + { + const char* kind; + draco::GeometryAttribute::Type type; + } namedAttributes[] = { + {"position", draco::GeometryAttribute::POSITION}, + {"normal", draco::GeometryAttribute::NORMAL}, + {"color", draco::GeometryAttribute::COLOR}, + {"uv", draco::GeometryAttribute::TEX_COORD}, + }; + + for (const auto& named : namedAttributes) + { + if (geometry->GetNamedAttributeId(named.type) != -1) + { + attributes.Set(attributeCount++, DecodeAttribute(env, *geometry, *geometry->GetNamedAttribute(named.type), named.kind)); + } + } + } + + auto result = Napi::Object::New(env); + result.Set("indices", indices); + result.Set("attributes", attributes); + result.Set("totalVertices", Napi::Number::New(env, static_cast(geometry->num_points()))); + return result; + } + } +} + +namespace Babylon::Plugins::NativeDraco +{ + void BABYLON_API Initialize(Napi::Env env) + { + auto native{JsRuntime::NativeObject::GetFromJavaScript(env)}; + native.Set("decodeDracoMesh", Napi::Function::New(env, DecodeDracoMesh, "decodeDracoMesh")); + } +} From fdb76ccc186ecdde96b9685e44cbb75957e36f42 Mon Sep 17 00:00:00 2001 From: bkaradzic-microsoft Date: Wed, 15 Jul 2026 09:27:24 -0700 Subject: [PATCH 02/10] Add native C++ Draco encoder to NativeDraco plugin 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 (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 --- Plugins/NativeDraco/Source/NativeDraco.cpp | 215 +++++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp index 3f63e5493..c3707756d 100644 --- a/Plugins/NativeDraco/Source/NativeDraco.cpp +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -4,15 +4,19 @@ #include #include +#include #include +#include #include #include #include #include #include +#include #include #include +#include namespace Babylon::Plugins { @@ -188,6 +192,216 @@ namespace Babylon::Plugins result.Set("totalVertices", Napi::Number::New(env, static_cast(geometry->num_points()))); return result; } + + // ---------------------------------------------------------------------------- + // Encoder + // ---------------------------------------------------------------------------- + + draco::GeometryAttribute::Type DracoAttributeTypeFromName(const std::string& name) + { + if (name == "POSITION") return draco::GeometryAttribute::POSITION; + if (name == "NORMAL") return draco::GeometryAttribute::NORMAL; + if (name == "COLOR") return draco::GeometryAttribute::COLOR; + if (name == "TEX_COORD") return draco::GeometryAttribute::TEX_COORD; + return draco::GeometryAttribute::GENERIC; + } + + // Returns a typed pointer to the start of the typed array's data, honoring its byte offset. + template + const T* TypedArrayData(const Napi::TypedArray& array) + { + const auto* base = static_cast(array.ArrayBuffer().Data()) + array.ByteOffset(); + return reinterpret_cast(base); + } + + // Replicates draco's emscripten PointCloudBuilder::AddAttribute: creates a de-interleaved + // per-point attribute and returns its attribute id (which equals its unique id, see + // PointCloud::SetAttribute -> set_unique_id). + template + int AddAttributeToMesh(draco::Mesh& mesh, draco::GeometryAttribute::Type type, draco::DataType dataType, long numVertices, int8_t numComponents, const T* values) + { + std::unique_ptr att(new draco::PointAttribute()); + att->Init(type, numComponents, dataType, /* normalized */ false, numVertices); + const int attId = mesh.AddAttribute(std::move(att)); + draco::PointAttribute* attPtr = mesh.attribute(attId); + for (draco::PointIndex i(0); i < numVertices; ++i) + { + attPtr->SetAttributeValue(attPtr->mapped_index(i), &values[static_cast(i.value()) * numComponents]); + } + if (mesh.num_points() == 0) + { + mesh.set_num_points(numVertices); + } + else if (mesh.num_points() != static_cast(numVertices)) + { + return -1; + } + return attId; + } + + // Dispatches AddAttributeToMesh on the typed array's element type, mirroring the WASM + // encoder's addAttributeMap. + int AddTypedAttributeToMesh(Napi::Env env, draco::Mesh& mesh, draco::GeometryAttribute::Type type, const Napi::TypedArray& data, int8_t numComponents) + { + const long numVertices = static_cast(data.ElementLength()) / numComponents; + switch (data.TypedArrayType()) + { + case napi_float32_array: return AddAttributeToMesh(mesh, type, draco::DT_FLOAT32, numVertices, numComponents, TypedArrayData(data)); + case napi_uint32_array: return AddAttributeToMesh(mesh, type, draco::DT_UINT32, numVertices, numComponents, TypedArrayData(data)); + case napi_uint16_array: return AddAttributeToMesh(mesh, type, draco::DT_UINT16, numVertices, numComponents, TypedArrayData(data)); + case napi_uint8_array: + case napi_uint8_clamped_array: return AddAttributeToMesh(mesh, type, draco::DT_UINT8, numVertices, numComponents, TypedArrayData(data)); + case napi_int32_array: return AddAttributeToMesh(mesh, type, draco::DT_INT32, numVertices, numComponents, TypedArrayData(data)); + case napi_int16_array: return AddAttributeToMesh(mesh, type, draco::DT_INT16, numVertices, numComponents, TypedArrayData(data)); + case napi_int8_array: return AddAttributeToMesh(mesh, type, draco::DT_INT8, numVertices, numComponents, TypedArrayData(data)); + default: + throw Napi::Error::New(env, "Draco: Unsupported attribute typed array for encoding"); + } + } + + // Reads an index typed array (Uint16Array or Uint32Array) into a flat int vector. + std::vector ReadIndices(const Napi::TypedArray& data) + { + const size_t count = data.ElementLength(); + std::vector out(count); + if (data.TypedArrayType() == napi_uint32_array) + { + const uint32_t* src = TypedArrayData(data); + for (size_t i = 0; i < count; ++i) { out[i] = static_cast(src[i]); } + } + else + { + const uint16_t* src = TypedArrayData(data); + for (size_t i = 0; i < count; ++i) { out[i] = static_cast(src[i]); } + } + return out; + } + + Napi::Value EncodeDracoMesh(const Napi::CallbackInfo& info) + { + auto env = info.Env(); + + if (info.Length() < 1 || !info[0].IsArray()) + { + throw Napi::TypeError::New(env, "encodeDracoMesh: expected an array of attributes"); + } + + const auto attributesIn = info[0].As(); + const auto options = (info.Length() > 2 && info[2].IsObject()) ? info[2].As() : Napi::Object::New(env); + + // Locate the mandatory position attribute and its vertex count. + long positionVerticesCount = 0; + bool hasPosition = false; + for (uint32_t i = 0; i < attributesIn.Length(); ++i) + { + const auto attr = attributesIn.Get(i).As(); + if (attr.Get("dracoName").As().Utf8Value() == "POSITION") + { + const auto data = attr.Get("data").As(); + const int8_t size = static_cast(attr.Get("size").As().Int32Value()); + positionVerticesCount = static_cast(data.ElementLength()) / size; + hasPosition = true; + break; + } + } + if (!hasPosition) + { + throw Napi::Error::New(env, "Draco: Missing position attribute for encoding."); + } + + // Indices: use the provided buffer, or synthesize an identity list for unindexed meshes. + std::vector indices; + if (info.Length() > 1 && info[1].IsTypedArray()) + { + indices = ReadIndices(info[1].As()); + } + else + { + indices.resize(positionVerticesCount); + for (long i = 0; i < positionVerticesCount; ++i) { indices[i] = static_cast(i); } + } + + draco::Mesh mesh; + const long numFaces = static_cast(indices.size()) / 3; + mesh.SetNumFaces(numFaces); + for (draco::FaceIndex f(0); f < numFaces; ++f) + { + draco::Mesh::Face face; + face[0] = draco::PointIndex(indices[f.value() * 3 + 0]); + face[1] = draco::PointIndex(indices[f.value() * 3 + 1]); + face[2] = draco::PointIndex(indices[f.value() * 3 + 2]); + mesh.SetFace(f, face); + } + + draco::Encoder encoder; + + const bool hasQuantization = options.Has("quantizationBits") && options.Get("quantizationBits").IsObject(); + const auto quantizationBits = hasQuantization ? options.Get("quantizationBits").As() : Napi::Object::New(env); + + auto attributeIds = Napi::Object::New(env); + for (uint32_t i = 0; i < attributesIn.Length(); ++i) + { + const auto attr = attributesIn.Get(i).As(); + const std::string kind = attr.Get("kind").As().Utf8Value(); + const std::string dracoName = attr.Get("dracoName").As().Utf8Value(); + const int8_t size = static_cast(attr.Get("size").As().Int32Value()); + const auto data = attr.Get("data").As(); + const draco::GeometryAttribute::Type type = DracoAttributeTypeFromName(dracoName); + + const int attId = AddTypedAttributeToMesh(env, mesh, type, data, size); + if (attId < 0) + { + throw Napi::Error::New(env, "Draco: Failed to add attribute '" + kind + "' (vertex count mismatch)."); + } + attributeIds.Set(kind, Napi::Number::New(env, attId)); + + if (hasQuantization && quantizationBits.Has(dracoName)) + { + const int32_t bits = quantizationBits.Get(dracoName).As().Int32Value(); + if (bits) // matches WASM path: only set for truthy (non-zero) values + { + encoder.SetAttributeQuantization(type, bits); + } + } + } + + if (options.Has("method") && options.Get("method").IsString()) + { + const std::string method = options.Get("method").As().Utf8Value(); + encoder.SetEncodingMethod(method == "MESH_SEQUENTIAL_ENCODING" ? draco::MESH_SEQUENTIAL_ENCODING : draco::MESH_EDGEBREAKER_ENCODING); + } + + if (options.Has("encodeSpeed") && options.Get("encodeSpeed").IsNumber() && options.Has("decodeSpeed") && options.Get("decodeSpeed").IsNumber()) + { + encoder.SetSpeedOptions(options.Get("encodeSpeed").As().Int32Value(), options.Get("decodeSpeed").As().Int32Value()); + } + + // Mirror Encoder::EncodeMeshToDracoBuffer. + if (mesh.GetNamedAttributeId(draco::GeometryAttribute::POSITION) == -1) + { + throw Napi::Error::New(env, "Draco: Missing position attribute for encoding."); + } + if (!mesh.DeduplicateAttributeValues()) + { + throw Napi::Error::New(env, "Draco: Failed to deduplicate attribute values."); + } + mesh.DeduplicatePointIds(); + + draco::EncoderBuffer buffer; + const draco::Status status = encoder.EncodeMeshToBuffer(mesh, &buffer); + if (!status.ok()) + { + throw Napi::Error::New(env, std::string("Draco: Failed to encode: ") + status.error_msg()); + } + + auto encodedData = Napi::Int8Array::New(env, buffer.size()); + std::memcpy(encodedData.Data(), buffer.data(), buffer.size()); + + auto result = Napi::Object::New(env); + result.Set("data", encodedData); + result.Set("attributeIds", attributeIds); + return result; + } } } @@ -197,5 +411,6 @@ namespace Babylon::Plugins::NativeDraco { auto native{JsRuntime::NativeObject::GetFromJavaScript(env)}; native.Set("decodeDracoMesh", Napi::Function::New(env, DecodeDracoMesh, "decodeDracoMesh")); + native.Set("encodeDracoMesh", Napi::Function::New(env, EncodeDracoMesh, "encodeDracoMesh")); } } From 3422197d08badeac69112067a800378122eb1902 Mon Sep 17 00:00:00 2001 From: bkaradzic-microsoft Date: Wed, 15 Jul 2026 10:24:23 -0700 Subject: [PATCH 03/10] Add native C++ meshopt decoder (NativeMeshopt plugin) 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 --- CMakeLists.txt | 4 + Dependencies/CMakeLists.txt | 14 +++ Embedding/CMakeLists.txt | 4 + Embedding/Source/Runtime.cpp | 2 + Plugins/CMakeLists.txt | 1 + Plugins/NativeMeshopt/CMakeLists.txt | 17 +++ .../Include/Babylon/Plugins/NativeMeshopt.h | 13 +++ .../NativeMeshopt/Source/NativeMeshopt.cpp | 106 ++++++++++++++++++ 8 files changed, 161 insertions(+) create mode 100644 Plugins/NativeMeshopt/CMakeLists.txt create mode 100644 Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h create mode 100644 Plugins/NativeMeshopt/Source/NativeMeshopt.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 54a19d0f8..019cf426d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,6 +49,10 @@ FetchContent_Declare(draco GIT_REPOSITORY https://github.com/google/draco.git GIT_TAG 1.5.7 EXCLUDE_FROM_ALL) +FetchContent_Declare(meshoptimizer + GIT_REPOSITORY https://github.com/zeux/meshoptimizer.git + GIT_TAG v0.22 + EXCLUDE_FROM_ALL) FetchContent_Declare(googletest URL "https://github.com/google/googletest/archive/refs/tags/v1.17.0.tar.gz" EXCLUDE_FROM_ALL) diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 2e01531c1..3b3e0e12e 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -206,6 +206,20 @@ if(NOT TARGET draco AND NOT TARGET draco_static) endforeach() endif() +# -------------------------------------------------- +# meshoptimizer (mesh decompression, native replacement for the WASM decoder) +# -------------------------------------------------- +if(NOT TARGET meshoptimizer) + set(MESHOPT_BUILD_DEMO OFF CACHE BOOL "" FORCE) + set(MESHOPT_BUILD_GLTFPACK OFF CACHE BOOL "" FORCE) + set(MESHOPT_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(MESHOPT_WERROR OFF CACHE BOOL "" FORCE) + + FetchContent_MakeAvailable_With_Message(meshoptimizer) + + set_property(TARGET meshoptimizer PROPERTY FOLDER Dependencies/meshoptimizer) +endif() + # -------------------------------------------------- # googletest # -------------------------------------------------- diff --git a/Embedding/CMakeLists.txt b/Embedding/CMakeLists.txt index 35679bf47..243a04458 100644 --- a/Embedding/CMakeLists.txt +++ b/Embedding/CMakeLists.txt @@ -111,6 +111,10 @@ endif() # Draco decoder that Babylon.js routes to when present. target_link_libraries(Embedding PRIVATE NativeDraco) +# NativeMeshopt is always built: it provides the native replacement for the WASM +# meshopt decoder that Babylon.js routes to when present. +target_link_libraries(Embedding PRIVATE NativeMeshopt) + if(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS) target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS=1) target_link_libraries(Embedding PRIVATE NativeOptimizations) diff --git a/Embedding/Source/Runtime.cpp b/Embedding/Source/Runtime.cpp index 8d3afa54f..ee1658004 100644 --- a/Embedding/Source/Runtime.cpp +++ b/Embedding/Source/Runtime.cpp @@ -6,6 +6,7 @@ #include #endif #include +#include #if BABYLON_NATIVE_PLUGIN_NATIVECAMERA #include #endif @@ -272,6 +273,7 @@ namespace Babylon::Embedding Babylon::Plugins::NativeEngine::Initialize(env); #endif Babylon::Plugins::NativeDraco::Initialize(env); + Babylon::Plugins::NativeMeshopt::Initialize(env); #if BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS Babylon::Plugins::NativeOptimizations::Initialize(env); #endif diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index f99c61e94..474da2a1b 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -19,6 +19,7 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE) endif() add_subdirectory(NativeDraco) +add_subdirectory(NativeMeshopt) if(BABYLON_NATIVE_PLUGIN_NATIVEINPUT) add_subdirectory(NativeInput) diff --git a/Plugins/NativeMeshopt/CMakeLists.txt b/Plugins/NativeMeshopt/CMakeLists.txt new file mode 100644 index 000000000..a1014b648 --- /dev/null +++ b/Plugins/NativeMeshopt/CMakeLists.txt @@ -0,0 +1,17 @@ +set(SOURCES + "Include/Babylon/Plugins/NativeMeshopt.h" + "Source/NativeMeshopt.cpp") + +add_library(NativeMeshopt ${SOURCES}) +warnings_as_errors(NativeMeshopt) + +target_include_directories(NativeMeshopt + PUBLIC "Include") + +target_link_libraries(NativeMeshopt + PUBLIC napi + PRIVATE JsRuntimeInternal + PRIVATE meshoptimizer) + +set_property(TARGET NativeMeshopt PROPERTY FOLDER Plugins) +source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES}) diff --git a/Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h b/Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h new file mode 100644 index 000000000..12f004aed --- /dev/null +++ b/Plugins/NativeMeshopt/Include/Babylon/Plugins/NativeMeshopt.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace Babylon::Plugins::NativeMeshopt +{ + // Exposes `_native.decodeMeshopt(source, count, stride, mode, filter?)`, a + // synchronous native replacement for Babylon's WebAssembly meshopt decoder + // (zeux/meshoptimizer). Babylon.js routes its MeshoptCompression to this + // function when it is present. + void BABYLON_API Initialize(Napi::Env env); +} diff --git a/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp new file mode 100644 index 000000000..799b82a7e --- /dev/null +++ b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp @@ -0,0 +1,106 @@ +#include +#include + +#include + +#include + +#include +#include +#include +#include + +namespace Babylon::Plugins +{ + namespace + { + // Native equivalent of MeshoptDecoder.decodeGltfBufferAsync: + // decodeMeshopt(source: Uint8Array, count, stride, mode, filter?) -> Uint8Array + // where mode is "ATTRIBUTES" | "TRIANGLES" | "INDICES" and filter (optional) + // is "NONE" | "OCTAHEDRAL" | "QUATERNION" | "EXPONENTIAL". Mirrors the + // reference meshopt_decoder.js decode() helper exactly: decode into a buffer + // sized for count rounded up to a multiple of 4, then apply the filter + // in-place over that rounded count, then return the first count*stride bytes. + Napi::Value DecodeMeshopt(const Napi::CallbackInfo& info) + { + const auto env = info.Env(); + + if (info.Length() < 4 || !info[0].IsTypedArray()) + { + throw Napi::TypeError::New(env, "Meshopt: decodeMeshopt(source, count, stride, mode, filter?) requires a source typed array."); + } + + const auto sourceArray = info[0].As(); + const auto* source = static_cast(sourceArray.ArrayBuffer().Data()) + sourceArray.ByteOffset(); + const size_t sourceSize = sourceArray.ByteLength(); + + const size_t count = static_cast(info[1].As().Int64Value()); + const size_t stride = static_cast(info[2].As().Int64Value()); + const std::string mode = info[3].As().Utf8Value(); + + // Round count up to a multiple of 4 (the reference decoder over-allocates + // and runs the vertex filter over count4 elements). + const size_t count4 = (count + 3) & ~static_cast(3); + + std::vector temp(count4 * stride, 0); + + int result; + if (mode == "ATTRIBUTES") + { + result = meshopt_decodeVertexBuffer(temp.data(), count, stride, source, sourceSize); + } + else if (mode == "TRIANGLES") + { + result = meshopt_decodeIndexBuffer(temp.data(), count, stride, source, sourceSize); + } + else if (mode == "INDICES") + { + result = meshopt_decodeIndexSequence(temp.data(), count, stride, source, sourceSize); + } + else + { + throw Napi::Error::New(env, std::string("Meshopt: Unsupported decode mode: ") + mode); + } + + if (result != 0) + { + throw Napi::Error::New(env, "Meshopt: Malformed buffer data: " + std::to_string(result)); + } + + if (info.Length() > 4 && info[4].IsString()) + { + const std::string filter = info[4].As().Utf8Value(); + if (filter == "OCTAHEDRAL") + { + meshopt_decodeFilterOct(temp.data(), count4, stride); + } + else if (filter == "QUATERNION") + { + meshopt_decodeFilterQuat(temp.data(), count4, stride); + } + else if (filter == "EXPONENTIAL") + { + meshopt_decodeFilterExp(temp.data(), count4, stride); + } + else if (filter != "NONE") + { + throw Napi::Error::New(env, std::string("Meshopt: Unsupported decode filter: ") + filter); + } + } + + const size_t outSize = count * stride; + auto output = Napi::Uint8Array::New(env, outSize); + std::memcpy(output.Data(), temp.data(), outSize); + return output; + } + } +} + +namespace Babylon::Plugins::NativeMeshopt +{ + void BABYLON_API Initialize(Napi::Env env) + { + auto native{JsRuntime::NativeObject::GetFromJavaScript(env)}; + native.Set("decodeMeshopt", Napi::Function::New(env, DecodeMeshopt, "decodeMeshopt")); + } +} From 5d890561b85625cb60bc6e9550b4a9e366238b32 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Tue, 28 Jul 2026 07:42:56 -0700 Subject: [PATCH 04/10] Address review feedback: gate plugins, validate inputs, fix 64->32 narrowing Fixes the macOS/iOS build break and the review comments on #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 --- CMakeLists.txt | 2 + Dependencies/CMakeLists.txt | 4 +- Embedding/CMakeLists.txt | 14 ++-- Embedding/Source/Runtime.cpp | 8 ++ Plugins/CMakeLists.txt | 15 ++-- Plugins/NativeDraco/Source/NativeDraco.cpp | 79 ++++++++++++++++--- .../NativeMeshopt/Source/NativeMeshopt.cpp | 54 +++++++++++-- 7 files changed, 144 insertions(+), 32 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 019cf426d..e6307e85d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -125,6 +125,7 @@ option(BABYLON_NATIVE_CHECK_THREAD_AFFINITY "Checks thread safety in the graphic option(BABYLON_NATIVE_PLUGIN_EXTERNALTEXTURE "Include Babylon Native Plugin ExternalTexture." ON) option(BABYLON_NATIVE_PLUGIN_NATIVECAMERA "Include Babylon Native Plugin NativeCamera." ON) option(BABYLON_NATIVE_PLUGIN_NATIVECAPTURE "Include Babylon Native Plugin NativeCapture." ON) +option(BABYLON_NATIVE_PLUGIN_NATIVEDRACO "Include Babylon Native Plugin NativeDraco." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEENCODING "Include Babylon Native Plugin NativeEncoding." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE "Include Babylon Native Plugin NativeEngine." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_WEBP "Enable WebP image parsing in NativeEngine (via bimg)." ON) @@ -135,6 +136,7 @@ if(NOT BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) endif() option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_COMPILESHADERS "Include Babylon Native Plugin NativeEngine - Compile Shaders." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEINPUT "Include Babylon Native Plugin NativeInput." ON) +option(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT "Include Babylon Native Plugin NativeMeshopt." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS "Include Babylon Native Plugin NativeOptimizations." ON) option(BABYLON_NATIVE_PLUGIN_NATIVETRACING "Include Babylon Native Plugin NativeTracing." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEXR "Include Babylon Native Plugin XR." ON) diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 3b3e0e12e..3cab8766a 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -170,7 +170,7 @@ endif() # -------------------------------------------------- # draco (mesh decompression, native replacement for the WASM decoder) # -------------------------------------------------- -if(NOT TARGET draco AND NOT TARGET draco_static) +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO AND NOT TARGET draco AND NOT TARGET draco_static) set(DRACO_TESTS OFF CACHE BOOL "" FORCE) set(DRACO_INSTALL OFF CACHE BOOL "" FORCE) set(DRACO_BUILD_EXECUTABLES OFF CACHE BOOL "" FORCE) @@ -209,7 +209,7 @@ endif() # -------------------------------------------------- # meshoptimizer (mesh decompression, native replacement for the WASM decoder) # -------------------------------------------------- -if(NOT TARGET meshoptimizer) +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT AND NOT TARGET meshoptimizer) set(MESHOPT_BUILD_DEMO OFF CACHE BOOL "" FORCE) set(MESHOPT_BUILD_GLTFPACK OFF CACHE BOOL "" FORCE) set(MESHOPT_BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) diff --git a/Embedding/CMakeLists.txt b/Embedding/CMakeLists.txt index 243a04458..b2f704726 100644 --- a/Embedding/CMakeLists.txt +++ b/Embedding/CMakeLists.txt @@ -107,13 +107,15 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEENCODING) target_link_libraries(Embedding PRIVATE NativeEncoding) endif() -# NativeDraco is always built: it provides the native replacement for the WASM -# Draco decoder that Babylon.js routes to when present. -target_link_libraries(Embedding PRIVATE NativeDraco) +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO) + target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEDRACO=1) + target_link_libraries(Embedding PRIVATE NativeDraco) +endif() -# NativeMeshopt is always built: it provides the native replacement for the WASM -# meshopt decoder that Babylon.js routes to when present. -target_link_libraries(Embedding PRIVATE NativeMeshopt) +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT) + target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=1) + target_link_libraries(Embedding PRIVATE NativeMeshopt) +endif() if(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS) target_compile_definitions(Embedding PUBLIC BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS=1) diff --git a/Embedding/Source/Runtime.cpp b/Embedding/Source/Runtime.cpp index ee1658004..c27945818 100644 --- a/Embedding/Source/Runtime.cpp +++ b/Embedding/Source/Runtime.cpp @@ -5,8 +5,12 @@ #if BABYLON_NATIVE_PLUGIN_NATIVEENGINE #include #endif +#if BABYLON_NATIVE_PLUGIN_NATIVEDRACO #include +#endif +#if BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT #include +#endif #if BABYLON_NATIVE_PLUGIN_NATIVECAMERA #include #endif @@ -272,8 +276,12 @@ namespace Babylon::Embedding #if BABYLON_NATIVE_PLUGIN_NATIVEENGINE Babylon::Plugins::NativeEngine::Initialize(env); #endif +#if BABYLON_NATIVE_PLUGIN_NATIVEDRACO Babylon::Plugins::NativeDraco::Initialize(env); +#endif +#if BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT Babylon::Plugins::NativeMeshopt::Initialize(env); +#endif #if BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS Babylon::Plugins::NativeOptimizations::Initialize(env); #endif diff --git a/Plugins/CMakeLists.txt b/Plugins/CMakeLists.txt index 474da2a1b..a5c82ee5d 100644 --- a/Plugins/CMakeLists.txt +++ b/Plugins/CMakeLists.txt @@ -10,21 +10,26 @@ if(BABYLON_NATIVE_PLUGIN_NATIVECAPTURE) add_subdirectory(NativeCapture) endif() +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO) + add_subdirectory(NativeDraco) +endif() + if(BABYLON_NATIVE_PLUGIN_NATIVEENCODING) add_subdirectory(NativeEncoding) endif() if(BABYLON_NATIVE_PLUGIN_NATIVEENGINE) add_subdirectory(NativeEngine) -endif() - -add_subdirectory(NativeDraco) -add_subdirectory(NativeMeshopt) + endif() -if(BABYLON_NATIVE_PLUGIN_NATIVEINPUT) + if(BABYLON_NATIVE_PLUGIN_NATIVEINPUT) add_subdirectory(NativeInput) endif() +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT) + add_subdirectory(NativeMeshopt) +endif() + if(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS) add_subdirectory(NativeOptimizations) endif() diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp index c3707756d..c0e40fcec 100644 --- a/Plugins/NativeDraco/Source/NativeDraco.cpp +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -218,7 +219,7 @@ namespace Babylon::Plugins // per-point attribute and returns its attribute id (which equals its unique id, see // PointCloud::SetAttribute -> set_unique_id). template - int AddAttributeToMesh(draco::Mesh& mesh, draco::GeometryAttribute::Type type, draco::DataType dataType, long numVertices, int8_t numComponents, const T* values) + int AddAttributeToMesh(draco::Mesh& mesh, draco::GeometryAttribute::Type type, draco::DataType dataType, uint32_t numVertices, int8_t numComponents, const T* values) { std::unique_ptr att(new draco::PointAttribute()); att->Init(type, numComponents, dataType, /* normalized */ false, numVertices); @@ -232,18 +233,41 @@ namespace Babylon::Plugins { mesh.set_num_points(numVertices); } - else if (mesh.num_points() != static_cast(numVertices)) + else if (mesh.num_points() != numVertices) { return -1; } return attId; } + // Reads and validates an attribute's component count. draco stores the component count as + // an int8_t, and divides the element count by it, so a non-positive or oversized value would + // either divide by zero or silently truncate. + int8_t ReadAttributeSize(Napi::Env env, const Napi::Object& attr, const Napi::TypedArray& data) + { + const int32_t size = attr.Get("size").As().Int32Value(); + if (size <= 0 || size > std::numeric_limits::max()) + { + throw Napi::RangeError::New(env, "Draco: attribute 'size' must be in [1, 127], got " + std::to_string(size)); + } + if (data.ElementLength() % static_cast(size) != 0) + { + throw Napi::RangeError::New(env, "Draco: attribute data length (" + std::to_string(data.ElementLength()) + + ") is not a multiple of its component count (" + std::to_string(size) + ")."); + } + const size_t numVertices = data.ElementLength() / static_cast(size); + if (static_cast(numVertices) > 0xFFFFFFFFull) + { + throw Napi::RangeError::New(env, "Draco: attribute has too many vertices: " + std::to_string(numVertices)); + } + return static_cast(size); + } + // Dispatches AddAttributeToMesh on the typed array's element type, mirroring the WASM // encoder's addAttributeMap. int AddTypedAttributeToMesh(Napi::Env env, draco::Mesh& mesh, draco::GeometryAttribute::Type type, const Napi::TypedArray& data, int8_t numComponents) { - const long numVertices = static_cast(data.ElementLength()) / numComponents; + const uint32_t numVertices = static_cast(data.ElementLength() / static_cast(numComponents)); switch (data.TypedArrayType()) { case napi_float32_array: return AddAttributeToMesh(mesh, type, draco::DT_FLOAT32, numVertices, numComponents, TypedArrayData(data)); @@ -260,11 +284,26 @@ namespace Babylon::Plugins } // Reads an index typed array (Uint16Array or Uint32Array) into a flat int vector. - std::vector ReadIndices(const Napi::TypedArray& data) + std::vector ReadIndices(Napi::Env env, const Napi::TypedArray& data) { + const auto arrayType = data.TypedArrayType(); + if (arrayType != napi_uint32_array && arrayType != napi_uint16_array) + { + throw Napi::TypeError::New(env, "Draco: indices must be a Uint16Array or a Uint32Array."); + } + const size_t count = data.ElementLength(); + if (count % 3 != 0) + { + throw Napi::RangeError::New(env, "Draco: index count (" + std::to_string(count) + ") is not a multiple of 3."); + } + if (static_cast(count / 3) > 0xFFFFFFFFull) + { + throw Napi::RangeError::New(env, "Draco: too many faces: " + std::to_string(count / 3)); + } + std::vector out(count); - if (data.TypedArrayType() == napi_uint32_array) + if (arrayType == napi_uint32_array) { const uint32_t* src = TypedArrayData(data); for (size_t i = 0; i < count; ++i) { out[i] = static_cast(src[i]); } @@ -290,7 +329,7 @@ namespace Babylon::Plugins const auto options = (info.Length() > 2 && info[2].IsObject()) ? info[2].As() : Napi::Object::New(env); // Locate the mandatory position attribute and its vertex count. - long positionVerticesCount = 0; + uint32_t positionVerticesCount = 0; bool hasPosition = false; for (uint32_t i = 0; i < attributesIn.Length(); ++i) { @@ -298,8 +337,8 @@ namespace Babylon::Plugins if (attr.Get("dracoName").As().Utf8Value() == "POSITION") { const auto data = attr.Get("data").As(); - const int8_t size = static_cast(attr.Get("size").As().Int32Value()); - positionVerticesCount = static_cast(data.ElementLength()) / size; + const int8_t size = ReadAttributeSize(env, attr, data); + positionVerticesCount = static_cast(data.ElementLength() / static_cast(size)); hasPosition = true; break; } @@ -313,16 +352,32 @@ namespace Babylon::Plugins std::vector indices; if (info.Length() > 1 && info[1].IsTypedArray()) { - indices = ReadIndices(info[1].As()); + indices = ReadIndices(env, info[1].As()); + + // Every index addresses a point in the position attribute; draco would otherwise + // read out of bounds when building the corner table. + for (const int index : indices) + { + if (index < 0 || static_cast(index) >= positionVerticesCount) + { + throw Napi::RangeError::New(env, "Draco: index " + std::to_string(index) + + " is out of range for " + std::to_string(positionVerticesCount) + " vertices."); + } + } } else { + if (positionVerticesCount % 3 != 0) + { + throw Napi::RangeError::New(env, "Draco: unindexed meshes need a vertex count that is a multiple of 3, got " + + std::to_string(positionVerticesCount) + "."); + } indices.resize(positionVerticesCount); - for (long i = 0; i < positionVerticesCount; ++i) { indices[i] = static_cast(i); } + for (uint32_t i = 0; i < positionVerticesCount; ++i) { indices[i] = static_cast(i); } } draco::Mesh mesh; - const long numFaces = static_cast(indices.size()) / 3; + const uint32_t numFaces = static_cast(indices.size() / 3); mesh.SetNumFaces(numFaces); for (draco::FaceIndex f(0); f < numFaces; ++f) { @@ -344,8 +399,8 @@ namespace Babylon::Plugins const auto attr = attributesIn.Get(i).As(); const std::string kind = attr.Get("kind").As().Utf8Value(); const std::string dracoName = attr.Get("dracoName").As().Utf8Value(); - const int8_t size = static_cast(attr.Get("size").As().Int32Value()); const auto data = attr.Get("data").As(); + const int8_t size = ReadAttributeSize(env, attr, data); const draco::GeometryAttribute::Type type = DracoAttributeTypeFromName(dracoName); const int attId = AddTypedAttributeToMesh(env, mesh, type, data, size); diff --git a/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp index 799b82a7e..b485f5b02 100644 --- a/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp +++ b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp @@ -34,14 +34,58 @@ namespace Babylon::Plugins const auto* source = static_cast(sourceArray.ArrayBuffer().Data()) + sourceArray.ByteOffset(); const size_t sourceSize = sourceArray.ByteLength(); - const size_t count = static_cast(info[1].As().Int64Value()); - const size_t stride = static_cast(info[2].As().Int64Value()); + const int64_t countIn = info[1].As().Int64Value(); + const int64_t strideIn = info[2].As().Int64Value(); const std::string mode = info[3].As().Utf8Value(); + // meshoptimizer validates these with assert(), which compiles out in release builds, + // so out-of-range values would be undefined behavior rather than a thrown error. + if (countIn < 0) + { + throw Napi::RangeError::New(env, "Meshopt: count must not be negative, got " + std::to_string(countIn)); + } + if (strideIn <= 0 || strideIn > 256) + { + throw Napi::RangeError::New(env, "Meshopt: stride must be in [1, 256], got " + std::to_string(strideIn)); + } + if (mode == "ATTRIBUTES") + { + if (strideIn % 4 != 0) + { + throw Napi::RangeError::New(env, "Meshopt: ATTRIBUTES stride must be a multiple of 4, got " + std::to_string(strideIn)); + } + } + else if (mode == "TRIANGLES" || mode == "INDICES") + { + if (strideIn != 2 && strideIn != 4) + { + throw Napi::RangeError::New(env, "Meshopt: " + mode + " stride must be 2 or 4, got " + std::to_string(strideIn)); + } + if (mode == "TRIANGLES" && countIn % 3 != 0) + { + throw Napi::RangeError::New(env, "Meshopt: TRIANGLES count must be a multiple of 3, got " + std::to_string(countIn)); + } + } + else + { + throw Napi::Error::New(env, "Meshopt: Unsupported decode mode: " + mode); + } + + const size_t count = static_cast(countIn); + const size_t stride = static_cast(strideIn); + // Round count up to a multiple of 4 (the reference decoder over-allocates // and runs the vertex filter over count4 elements). const size_t count4 = (count + 3) & ~static_cast(3); + // Guard the allocation size so a huge count cannot wrap size_t. + constexpr int64_t maxDecodedBytes = 1LL << 31; + if (static_cast(count4) * strideIn > maxDecodedBytes) + { + throw Napi::RangeError::New(env, "Meshopt: decoded size (" + std::to_string(count4) + " x " + + std::to_string(strideIn) + " bytes) exceeds the 2 GB limit."); + } + std::vector temp(count4 * stride, 0); int result; @@ -53,13 +97,9 @@ namespace Babylon::Plugins { result = meshopt_decodeIndexBuffer(temp.data(), count, stride, source, sourceSize); } - else if (mode == "INDICES") - { - result = meshopt_decodeIndexSequence(temp.data(), count, stride, source, sourceSize); - } else { - throw Napi::Error::New(env, std::string("Meshopt: Unsupported decode mode: ") + mode); + result = meshopt_decodeIndexSequence(temp.data(), count, stride, source, sourceSize); } if (result != 0) From d784a8891f11d6376b4f3e36522eded22c852efd Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Tue, 28 Jul 2026 12:19:39 -0700 Subject: [PATCH 05/10] Group all draco targets under Dependencies/draco in IDE project trees 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 --- Dependencies/CMakeLists.txt | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index 3cab8766a..a085cd610 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -167,6 +167,26 @@ if(NOT TARGET glslang) set_property(TARGET SPIRV PROPERTY FOLDER Dependencies/glslang) endif() +# Assigns an IDE folder to every target a dependency declares, including those in +# nested directories. draco alone contributes ~25 internal object libraries; listing +# them by name would silently go stale whenever the dependency reorganizes its build, +# leaving targets ungrouped at the solution root. +function(group_directory_targets DIR FOLDER_NAME) + get_property(_targets DIRECTORY "${DIR}" PROPERTY BUILDSYSTEM_TARGETS) + foreach(_target IN LISTS _targets) + # INTERFACE libraries have no build output and cannot carry a FOLDER. + get_target_property(_type ${_target} TYPE) + if(NOT _type STREQUAL "INTERFACE_LIBRARY") + set_property(TARGET ${_target} PROPERTY FOLDER "${FOLDER_NAME}") + endif() + endforeach() + + get_property(_subdirs DIRECTORY "${DIR}" PROPERTY SUBDIRECTORIES) + foreach(_subdir IN LISTS _subdirs) + group_directory_targets("${_subdir}" "${FOLDER_NAME}") + endforeach() +endfunction() + # -------------------------------------------------- # draco (mesh decompression, native replacement for the WASM decoder) # -------------------------------------------------- @@ -198,12 +218,8 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO AND NOT TARGET draco AND NOT TARGET draco_s "${draco_BINARY_DIR}" "${CMAKE_BINARY_DIR}") - # Assign IDE folders for whichever draco targets exist. - foreach(_draco_target IN ITEMS draco draco_static) - if(TARGET ${_draco_target}) - set_property(TARGET ${_draco_target} PROPERTY FOLDER Dependencies/draco) - endif() - endforeach() + # Assign IDE folders for every draco target, not just the combined library. + group_directory_targets("${draco_SOURCE_DIR}" Dependencies/draco) endif() # -------------------------------------------------- @@ -217,7 +233,7 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT AND NOT TARGET meshoptimizer) FetchContent_MakeAvailable_With_Message(meshoptimizer) - set_property(TARGET meshoptimizer PROPERTY FOLDER Dependencies/meshoptimizer) + group_directory_targets("${meshoptimizer_SOURCE_DIR}" Dependencies/meshoptimizer) endif() # -------------------------------------------------- From 726c580387fc4b36d7326d12353c117b3ac8548f Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 29 Jul 2026 07:53:58 -0700 Subject: [PATCH 06/10] Group the codec entry points and add unit test coverage 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 --- .github/workflows/build-linux.yml | 2 + .github/workflows/build-macos.yml | 2 + .github/workflows/build-win32.yml | 2 + Apps/UnitTests/CMakeLists.txt | 12 ++ .../JavaScript/dist/tests.javaScript.all.js | 176 ++++++++++++++++++ .../JavaScript/src/tests.javaScript.all.ts | 176 ++++++++++++++++++ Apps/UnitTests/Source/Tests.JavaScript.cpp | 12 ++ CMakeLists.txt | 4 +- Plugins/NativeDraco/Source/NativeDraco.cpp | 20 +- .../NativeMeshopt/Source/NativeMeshopt.cpp | 25 ++- 10 files changed, 425 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index aa60abf74..d6b288f7f 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -49,6 +49,8 @@ jobs: -D BX_CONFIG_DEBUG=ON \ -D OpenGL_GL_PREFERENCE=GLVND \ -D BABYLON_DEBUG_TRACE=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ ${{ inputs.js-engine == 'Hermes' && '-D HERMES_UNICODE_LITE=ON -D HERMES_ALLOW_BOOST_CONTEXT=0' || '' }} \ -D ENABLE_SANITIZERS=${{ inputs.enable-sanitizers && 'ON' || 'OFF' }} . ninja -C build/Linux diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 8437034c5..8f735220f 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -47,6 +47,8 @@ jobs: cmake -G "${{ inputs.generator }}" -B build/macOS \ ${{ inputs.js-engine != '' && format('-D NAPI_JAVASCRIPT_ENGINE={0}', inputs.js-engine) || '' }} \ -D BABYLON_DEBUG_TRACE=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ -D ENABLE_SANITIZERS=${{ inputs.enable-sanitizers && 'ON' || 'OFF' }} \ -D BABYLON_NATIVE_TESTS_USE_NOOP_METAL_DEVICE=ON diff --git a/.github/workflows/build-win32.yml b/.github/workflows/build-win32.yml index b73bd85bb..64198185b 100644 --- a/.github/workflows/build-win32.yml +++ b/.github/workflows/build-win32.yml @@ -67,6 +67,8 @@ jobs: -D GRAPHICS_API=${{ inputs.graphics-api }} ^ -D BGFX_CONFIG_MAX_FRAME_BUFFERS=256 ^ -D BABYLON_DEBUG_TRACE=ON ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON ^ -D ENABLE_SANITIZERS=${{ steps.vars.outputs.sanitizer_flag }} - name: Build diff --git a/Apps/UnitTests/CMakeLists.txt b/Apps/UnitTests/CMakeLists.txt index 4ffe04381..5993f2c4e 100644 --- a/Apps/UnitTests/CMakeLists.txt +++ b/Apps/UnitTests/CMakeLists.txt @@ -85,6 +85,18 @@ target_link_libraries(UnitTests target_compile_definitions(UnitTests PRIVATE ${ADDITIONAL_COMPILE_DEFINITIONS}) +# NativeDraco and NativeMeshopt default to OFF, so link and exercise them only when the +# consuming build opted in. CI turns both on for the jobs that run UnitTests. +if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO) + target_link_libraries(UnitTests PRIVATE NativeDraco) + target_compile_definitions(UnitTests PRIVATE HAS_NATIVE_DRACO) +endif() + +if(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT) + target_link_libraries(UnitTests PRIVATE NativeMeshopt) + target_compile_definitions(UnitTests PRIVATE HAS_NATIVE_MESHOPT) +endif() + if(GRAPHICS_API STREQUAL "D3D12") target_compile_definitions(UnitTests PRIVATE SKIP_RENDER_TESTS) endif() diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 53a4d4c17..169a474c2 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28559,6 +28559,182 @@ describe("NativeEncoding", function () { ); }); +function hexToBytes(hex) { + var out = new Uint8Array(hex.length / 2); + for (var i = 0; i < out.length; ++i) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} + +// Both plugins default to OFF, so report them as skipped rather than silently passing +// when the build did not opt in. CI enables both for the jobs that run UnitTests. +(typeof _native.DracoCodec !== "undefined" ? describe : describe.skip)("NativeDraco", function () { + this.timeout(0); + + // Two triangles sharing an edge. Values are exact halves so they survive the float32 + // round trip bit-for-bit once quantization is disabled. + var positions = new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + 1, 1, 0] + ); + var indices = new Uint16Array([0, 1, 2, 1, 3, 2]); + + function encodeFixture() { + return _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], + indices); + } + + it("publishes the codec version it was built against", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Version).to.be.a("string"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Version).to.match(/^\d+\.\d+\.\d+$/); + }); + + it("round trips a mesh through encode and decode", function () { + var encoded = encodeFixture(); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(encoded.data).to.be.instanceOf(Uint8Array); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(encoded.data.length).to.be.greaterThan(0); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(encoded.attributeIds).to.have.property("position"); + + var decoded = _native.DracoCodec.Decode(encoded.data, encoded.attributeIds); + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.totalVertices).to.equal(positions.length / 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.indices.length).to.equal(indices.length); + + // Draco reorders points, so compare the triangles as sets of resolved corner + // positions rather than assuming the original vertex order survived. Rounded to + // two decimals so the comparison tolerates quantization but still separates + // coordinates that are a whole unit apart. + var attribute = decoded.attributes.find(function (a) {return a.kind === "position";}); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(attribute, "decoded position attribute").to.not.equal(undefined); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(attribute.size).to.equal(3); + + var corner = function corner(buffer, i) {return ( + [buffer[i * 3], buffer[i * 3 + 1], buffer[i * 3 + 2]]. + map(function (v) {return v.toFixed(2);}). + join(","));}; + + var expectedCorners = []; + var actualCorners = []; + for (var i = 0; i < indices.length; ++i) { + expectedCorners.push(corner(positions, indices[i])); + actualCorners.push(corner(attribute.data, decoded.indices[i])); + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(actualCorners.sort()).to.deep.equal(expectedCorners.sort()); + }); + + it("rejects malformed input", function () { + var garbage = new Uint8Array(64); + for (var i = 0; i < garbage.length; ++i) { + garbage[i] = i * 37 & 0xff; + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(garbage);}).to.throw(); + }); + + it("rejects truncated input", function () { + var encoded = encodeFixture(); + var truncated = encoded.data.slice(0, Math.floor(encoded.data.length / 2)); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(truncated);}).to.throw(); + }); + + it("rejects an empty buffer", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(new Uint8Array(0));}).to.throw(); + }); + + it("rejects encoding without a position attribute", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( + [{ kind: "normal", dracoName: "NORMAL", data: positions, size: 3 }], + indices);}).to.throw(); + }); + + it("rejects an index that is out of range for the vertex count", function () { + var bad = new Uint16Array([0, 1, 99, 1, 3, 2]); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], + bad);}).to.throw(/out of range/); + }); + + it("rejects a non-positive attribute size", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: positions, size: 0 }], + indices);}).to.throw(/size/); + }); + + it("rejects attribute data that is not a multiple of its component count", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: new Float32Array(7), size: 3 }], + indices);}).to.throw(/multiple/); + }); +}); + +(typeof _native.MeshoptCodec !== "undefined" ? describe : describe.skip)("NativeMeshopt", function () { + this.timeout(0); + + // Produced by the reference meshoptimizer 0.22 JavaScript encoder + // (MeshoptEncoder.encodeVertexBuffer) over 6 vertices of 16-byte stride, so this + // pins our native decoder against the upstream bitstream rather than against itself. + var ENCODED = hexToBytes( + "a00000013ff000007fffa0606001380000007e0000013ff0000020ff9070480130800000800000013ff0000080ff" + + "a0606001320000007e012aa000000000000000000000000000000000000000000000000000000000800000000000beadde"); + var EXPECTED = hexToBytes( + "00000000000000800000000000beadde0000c03f000010c00000403f01beadde00004040000090c00000c03f02beadde" + + "000090400000d8c00000104003beadde0000c040000010c10000404004beadde0000f040000034c10000704005beadde"); + var COUNT = 6; + var STRIDE = 16; + + it("publishes the codec version it was built against", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.MeshoptCodec.Version).to.be.a("string"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.MeshoptCodec.Version).to.match(/^\d+\.\d+$/); + }); + + it("decodes a reference stream byte for byte", function () { + var decoded = _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "ATTRIBUTES"); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.length).to.equal(EXPECTED.length); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Array.from(decoded)).to.deep.equal(Array.from(EXPECTED)); + }); + + it("rejects malformed input", function () { + var garbage = new Uint8Array(ENCODED.length); + for (var i = 0; i < garbage.length; ++i) { + garbage[i] = i * 37 & 0xff; + } + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(garbage, COUNT, STRIDE, "ATTRIBUTES");}).to.throw(); + }); + + it("rejects truncated input", function () { + var truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(truncated, COUNT, STRIDE, "ATTRIBUTES");}).to.throw(); + }); + + it("rejects an unknown mode", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "NOT_A_MODE");}).to.throw(); + }); + + it("rejects a stride outside [1, 256]", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES");}).to.throw(/stride/); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES");}).to.throw(/stride/); + }); + + it("rejects an ATTRIBUTES stride that is not a multiple of 4", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES");}).to.throw(/multiple of 4/); + }); + + it("rejects a negative count", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES");}).to.throw(/count/); + }); + + it("rejects a TRIANGLES count that is not a multiple of 3", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES");}).to.throw(/multiple of 3/); + }); + + it("rejects a non-typed-array source", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(null, COUNT, STRIDE, "ATTRIBUTES");}).to.throw(); + }); +}); + mocha.run(function (failures) { // Test program will wait for code to be set before exiting if (failures > 0) { diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index e7ebaacbe..f86657556 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -328,6 +328,182 @@ describe("NativeEncoding", function () { }); }); +function hexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2); + for (let i = 0; i < out.length; ++i) { + out[i] = parseInt(hex.substr(i * 2, 2), 16); + } + return out; +} + +// Both plugins default to OFF, so report them as skipped rather than silently passing +// when the build did not opt in. CI enables both for the jobs that run UnitTests. +(typeof _native.DracoCodec !== "undefined" ? describe : describe.skip)("NativeDraco", function () { + this.timeout(0); + + // Two triangles sharing an edge. Values are exact halves so they survive the float32 + // round trip bit-for-bit once quantization is disabled. + const positions = new Float32Array([ + 0, 0, 0, + 1, 0, 0, + 0, 1, 0, + 1, 1, 0, + ]); + const indices = new Uint16Array([0, 1, 2, 1, 3, 2]); + + function encodeFixture() { + return _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], + indices); + } + + it("publishes the codec version it was built against", function () { + expect(_native.DracoCodec.Version).to.be.a("string"); + expect(_native.DracoCodec.Version).to.match(/^\d+\.\d+\.\d+$/); + }); + + it("round trips a mesh through encode and decode", function () { + const encoded = encodeFixture(); + expect(encoded.data).to.be.instanceOf(Uint8Array); + expect(encoded.data.length).to.be.greaterThan(0); + expect(encoded.attributeIds).to.have.property("position"); + + const decoded = _native.DracoCodec.Decode(encoded.data, encoded.attributeIds); + + expect(decoded.totalVertices).to.equal(positions.length / 3); + expect(decoded.indices.length).to.equal(indices.length); + + // Draco reorders points, so compare the triangles as sets of resolved corner + // positions rather than assuming the original vertex order survived. Rounded to + // two decimals so the comparison tolerates quantization but still separates + // coordinates that are a whole unit apart. + const attribute = decoded.attributes.find((a: any) => a.kind === "position"); + expect(attribute, "decoded position attribute").to.not.equal(undefined); + expect(attribute.size).to.equal(3); + + const corner = (buffer: any, i: number) => + [buffer[i * 3], buffer[i * 3 + 1], buffer[i * 3 + 2]] + .map((v: number) => v.toFixed(2)) + .join(","); + + const expectedCorners: string[] = []; + const actualCorners: string[] = []; + for (let i = 0; i < indices.length; ++i) { + expectedCorners.push(corner(positions, indices[i])); + actualCorners.push(corner(attribute.data, decoded.indices[i])); + } + expect(actualCorners.sort()).to.deep.equal(expectedCorners.sort()); + }); + + it("rejects malformed input", function () { + const garbage = new Uint8Array(64); + for (let i = 0; i < garbage.length; ++i) { + garbage[i] = (i * 37) & 0xff; + } + expect(() => _native.DracoCodec.Decode(garbage)).to.throw(); + }); + + it("rejects truncated input", function () { + const encoded = encodeFixture(); + const truncated = encoded.data.slice(0, Math.floor(encoded.data.length / 2)); + expect(() => _native.DracoCodec.Decode(truncated)).to.throw(); + }); + + it("rejects an empty buffer", function () { + expect(() => _native.DracoCodec.Decode(new Uint8Array(0))).to.throw(); + }); + + it("rejects encoding without a position attribute", function () { + expect(() => _native.DracoCodec.Encode( + [{ kind: "normal", dracoName: "NORMAL", data: positions, size: 3 }], + indices)).to.throw(); + }); + + it("rejects an index that is out of range for the vertex count", function () { + const bad = new Uint16Array([0, 1, 99, 1, 3, 2]); + expect(() => _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], + bad)).to.throw(/out of range/); + }); + + it("rejects a non-positive attribute size", function () { + expect(() => _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: positions, size: 0 }], + indices)).to.throw(/size/); + }); + + it("rejects attribute data that is not a multiple of its component count", function () { + expect(() => _native.DracoCodec.Encode( + [{ kind: "position", dracoName: "POSITION", data: new Float32Array(7), size: 3 }], + indices)).to.throw(/multiple/); + }); +}); + +(typeof _native.MeshoptCodec !== "undefined" ? describe : describe.skip)("NativeMeshopt", function () { + this.timeout(0); + + // Produced by the reference meshoptimizer 0.22 JavaScript encoder + // (MeshoptEncoder.encodeVertexBuffer) over 6 vertices of 16-byte stride, so this + // pins our native decoder against the upstream bitstream rather than against itself. + const ENCODED = hexToBytes( + "a00000013ff000007fffa0606001380000007e0000013ff0000020ff9070480130800000800000013ff0000080ff" + + "a0606001320000007e012aa000000000000000000000000000000000000000000000000000000000800000000000beadde"); + const EXPECTED = hexToBytes( + "00000000000000800000000000beadde0000c03f000010c00000403f01beadde00004040000090c00000c03f02beadde" + + "000090400000d8c00000104003beadde0000c040000010c10000404004beadde0000f040000034c10000704005beadde"); + const COUNT = 6; + const STRIDE = 16; + + it("publishes the codec version it was built against", function () { + expect(_native.MeshoptCodec.Version).to.be.a("string"); + expect(_native.MeshoptCodec.Version).to.match(/^\d+\.\d+$/); + }); + + it("decodes a reference stream byte for byte", function () { + const decoded = _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "ATTRIBUTES"); + expect(decoded.length).to.equal(EXPECTED.length); + expect(Array.from(decoded)).to.deep.equal(Array.from(EXPECTED)); + }); + + it("rejects malformed input", function () { + const garbage = new Uint8Array(ENCODED.length); + for (let i = 0; i < garbage.length; ++i) { + garbage[i] = (i * 37) & 0xff; + } + expect(() => _native.MeshoptCodec.Decode(garbage, COUNT, STRIDE, "ATTRIBUTES")).to.throw(); + }); + + it("rejects truncated input", function () { + const truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); + expect(() => _native.MeshoptCodec.Decode(truncated, COUNT, STRIDE, "ATTRIBUTES")).to.throw(); + }); + + it("rejects an unknown mode", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, STRIDE, "NOT_A_MODE")).to.throw(); + }); + + it("rejects a stride outside [1, 256]", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES")).to.throw(/stride/); + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES")).to.throw(/stride/); + }); + + it("rejects an ATTRIBUTES stride that is not a multiple of 4", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES")).to.throw(/multiple of 4/); + }); + + it("rejects a negative count", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES")).to.throw(/count/); + }); + + it("rejects a TRIANGLES count that is not a multiple of 3", function () { + expect(() => _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES")).to.throw(/multiple of 3/); + }); + + it("rejects a non-typed-array source", function () { + expect(() => _native.MeshoptCodec.Decode(null, COUNT, STRIDE, "ATTRIBUTES")).to.throw(); + }); +}); + mocha.run((failures) => { // Test program will wait for code to be set before exiting if (failures > 0) { diff --git a/Apps/UnitTests/Source/Tests.JavaScript.cpp b/Apps/UnitTests/Source/Tests.JavaScript.cpp index 817d3f06d..3eb048ca6 100644 --- a/Apps/UnitTests/Source/Tests.JavaScript.cpp +++ b/Apps/UnitTests/Source/Tests.JavaScript.cpp @@ -9,6 +9,12 @@ #include #include #include +#ifdef HAS_NATIVE_DRACO +#include +#endif +#ifdef HAS_NATIVE_MESHOPT +#include +#endif #include #include @@ -78,6 +84,12 @@ TEST(JavaScript, All) nativeCanvas.emplace(Babylon::Polyfills::Canvas::Initialize(env)); Babylon::Plugins::NativeEngine::Initialize(env); Babylon::Plugins::NativeEncoding::Initialize(env); +#ifdef HAS_NATIVE_DRACO + Babylon::Plugins::NativeDraco::Initialize(env); +#endif +#ifdef HAS_NATIVE_MESHOPT + Babylon::Plugins::NativeMeshopt::Initialize(env); +#endif auto setExitCodeCallback = Napi::Function::New( env, [&exitCodePromise](const Napi::CallbackInfo& info) { diff --git a/CMakeLists.txt b/CMakeLists.txt index e6307e85d..66f2e9879 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -125,7 +125,7 @@ option(BABYLON_NATIVE_CHECK_THREAD_AFFINITY "Checks thread safety in the graphic option(BABYLON_NATIVE_PLUGIN_EXTERNALTEXTURE "Include Babylon Native Plugin ExternalTexture." ON) option(BABYLON_NATIVE_PLUGIN_NATIVECAMERA "Include Babylon Native Plugin NativeCamera." ON) option(BABYLON_NATIVE_PLUGIN_NATIVECAPTURE "Include Babylon Native Plugin NativeCapture." ON) -option(BABYLON_NATIVE_PLUGIN_NATIVEDRACO "Include Babylon Native Plugin NativeDraco." ON) +option(BABYLON_NATIVE_PLUGIN_NATIVEDRACO "Include Babylon Native Plugin NativeDraco." OFF) option(BABYLON_NATIVE_PLUGIN_NATIVEENCODING "Include Babylon Native Plugin NativeEncoding." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE "Include Babylon Native Plugin NativeEngine." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_WEBP "Enable WebP image parsing in NativeEngine (via bimg)." ON) @@ -136,7 +136,7 @@ if(NOT BABYLON_NATIVE_PLUGIN_NATIVEENGINE_LOAD_IMAGES) endif() option(BABYLON_NATIVE_PLUGIN_NATIVEENGINE_COMPILESHADERS "Include Babylon Native Plugin NativeEngine - Compile Shaders." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEINPUT "Include Babylon Native Plugin NativeInput." ON) -option(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT "Include Babylon Native Plugin NativeMeshopt." ON) +option(BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT "Include Babylon Native Plugin NativeMeshopt." OFF) option(BABYLON_NATIVE_PLUGIN_NATIVEOPTIMIZATIONS "Include Babylon Native Plugin NativeOptimizations." ON) option(BABYLON_NATIVE_PLUGIN_NATIVETRACING "Include Babylon Native Plugin NativeTracing." ON) option(BABYLON_NATIVE_PLUGIN_NATIVEXR "Include Babylon Native Plugin XR." ON) diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp index c0e40fcec..ca896f992 100644 --- a/Plugins/NativeDraco/Source/NativeDraco.cpp +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -449,7 +450,11 @@ namespace Babylon::Plugins throw Napi::Error::New(env, std::string("Draco: Failed to encode: ") + status.error_msg()); } - auto encodedData = Napi::Int8Array::New(env, buffer.size()); + // Uint8Array, not Int8Array: this is opaque binary output, and every other binary + // value crossing this boundary (MeshoptCodec.Decode, the ImageBitmap paths) is + // unsigned. Int8Array would surface every byte above 127 as a negative number to + // callers that index it directly. + auto encodedData = Napi::Uint8Array::New(env, buffer.size()); std::memcpy(encodedData.Data(), buffer.data(), buffer.size()); auto result = Napi::Object::New(env); @@ -465,7 +470,16 @@ namespace Babylon::Plugins::NativeDraco 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")); + + // Exposed as a single object rather than free functions so that the JavaScript side + // needs one feature probe instead of one per entry point, and so the object can carry + // the version of the codec built into this binary. A bare function name cannot express + // that, and Draco's bitstream is versioned, so a caller holding a stream this build is + // too old to read has no other way to find out ahead of time. + auto codec = Napi::Object::New(env); + codec.Set("Decode", Napi::Function::New(env, DecodeDracoMesh, "Decode")); + codec.Set("Encode", Napi::Function::New(env, EncodeDracoMesh, "Encode")); + codec.Set("Version", Napi::String::New(env, draco::kDracoVersion)); + native.Set("DracoCodec", codec); } } diff --git a/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp index b485f5b02..c9ae2f08b 100644 --- a/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp +++ b/Plugins/NativeMeshopt/Source/NativeMeshopt.cpp @@ -5,6 +5,14 @@ #include +// bgfx vendors its own copy of meshoptimizer under bgfx/3rdparty/meshoptimizer. It is reached +// as rather than , so it does not collide +// today, but the guard in Dependencies/CMakeLists.txt skips our FetchContent when a target named +// meshoptimizer already exists. If that ever resolves the other way we would decode with a +// different codec version than the one this file was written against, which fails as silent +// data corruption rather than a build break. Pin it. +static_assert(MESHOPTIMIZER_VERSION == 220, "NativeMeshopt expects meshoptimizer 0.22; check the include path and revalidate the decode paths before bumping."); + #include #include #include @@ -14,6 +22,13 @@ namespace Babylon::Plugins { namespace { + // MESHOPTIMIZER_VERSION is an integer like 220 meaning 0.22. Render it the way the + // project versions its own releases so the JavaScript side can compare it directly + // against the version a stream was produced with. + std::string MeshoptVersionString() + { + return std::to_string(MESHOPTIMIZER_VERSION / 1000) + "." + std::to_string((MESHOPTIMIZER_VERSION / 10) % 100); + } // Native equivalent of MeshoptDecoder.decodeGltfBufferAsync: // decodeMeshopt(source: Uint8Array, count, stride, mode, filter?) -> Uint8Array // where mode is "ATTRIBUTES" | "TRIANGLES" | "INDICES" and filter (optional) @@ -141,6 +156,14 @@ namespace Babylon::Plugins::NativeMeshopt void BABYLON_API Initialize(Napi::Env env) { auto native{JsRuntime::NativeObject::GetFromJavaScript(env)}; - native.Set("decodeMeshopt", Napi::Function::New(env, DecodeMeshopt, "decodeMeshopt")); + + // Grouped for the same reasons as DracoCodec. Version matters more here: meshoptimizer + // stores its codec version in the first header byte and a decoder rejects streams newer + // than it understands, returning an error rather than degraded output. Publishing the + // version lets the JavaScript side fall back before it tries. + auto codec = Napi::Object::New(env); + codec.Set("Decode", Napi::Function::New(env, DecodeMeshopt, "Decode")); + codec.Set("Version", Napi::String::New(env, MeshoptVersionString())); + native.Set("MeshoptCodec", codec); } } From 073e5babdc6b69ecd33b725567199dde1f976ad8 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 29 Jul 2026 08:12:37 -0700 Subject: [PATCH 07/10] Avoid GetPropertyNames, which is broken on JavaScriptCore 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 --- Plugins/NativeDraco/Source/NativeDraco.cpp | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp index ca896f992..1de1fc10c 100644 --- a/Plugins/NativeDraco/Source/NativeDraco.cpp +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -24,6 +24,23 @@ namespace Babylon::Plugins { namespace { + // Enumerates an object's own keys. + // + // Deliberately not Napi::Object::GetPropertyNames(). JsRuntimeHost's JavaScriptCore + // backend implements napi_get_property_names by calling Object.getOwnPropertyNames with + // an argument count of zero, so the object under inspection is never passed and the call + // evaluates getOwnPropertyNames(undefined), which throws. That makes GetPropertyNames + // unusable on JavaScriptCore, which is the default engine on macOS and iOS. Calling + // Object.keys through the global object goes through napi_call_function with the + // argument actually supplied, and behaves identically on every engine for the plain data + // objects this map is built from. + Napi::Array OwnPropertyNames(Napi::Env env, const Napi::Object& object) + { + const auto objectCtor = env.Global().Get("Object").As(); + const auto keys = objectCtor.Get("keys").As(); + return keys.Call(objectCtor, {object}).As(); + } + // De-interleaves and tightly packs one Draco attribute's per-point values into a // freshly allocated JS typed array of type T. This mirrors emscripten's // GetAttributeDataArrayForAllPoints, which Babylon's WASM decoder relies on. @@ -153,7 +170,7 @@ namespace Babylon::Plugins { // glTF path: caller provides a map of Babylon vertex-buffer kind -> Draco unique id. const auto attributeIds = info[1].As(); - const auto keys = attributeIds.GetPropertyNames(); + const auto keys = OwnPropertyNames(env, attributeIds); for (uint32_t i = 0; i < keys.Length(); ++i) { const auto kind = keys.Get(i).As().Utf8Value(); From 19403b4caf22cbe32c49c2081a16dcac4f3fabf2 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 29 Jul 2026 08:37:29 -0700 Subject: [PATCH 08/10] Assert only that invalid codec input throws, not the message text 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 --- .../JavaScript/dist/tests.javaScript.all.js | 16 ++++++++-------- .../JavaScript/src/tests.javaScript.all.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index 169a474c2..ce97150a5 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28654,19 +28654,19 @@ function hexToBytes(hex) { var bad = new Uint16Array([0, 1, 99, 1, 3, 2]); (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], - bad);}).to.throw(/out of range/); + bad);}).to.throw(); }); it("rejects a non-positive attribute size", function () { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( [{ kind: "position", dracoName: "POSITION", data: positions, size: 0 }], - indices);}).to.throw(/size/); + indices);}).to.throw(); }); it("rejects attribute data that is not a multiple of its component count", function () { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( [{ kind: "position", dracoName: "POSITION", data: new Float32Array(7), size: 3 }], - indices);}).to.throw(/multiple/); + indices);}).to.throw(); }); }); @@ -28714,20 +28714,20 @@ function hexToBytes(hex) { }); it("rejects a stride outside [1, 256]", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES");}).to.throw(/stride/); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES");}).to.throw(/stride/); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES");}).to.throw(); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES");}).to.throw(); }); it("rejects an ATTRIBUTES stride that is not a multiple of 4", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES");}).to.throw(/multiple of 4/); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES");}).to.throw(); }); it("rejects a negative count", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES");}).to.throw(/count/); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES");}).to.throw(); }); it("rejects a TRIANGLES count that is not a multiple of 3", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES");}).to.throw(/multiple of 3/); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES");}).to.throw(); }); it("rejects a non-typed-array source", function () { diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index f86657556..f73d83d7c 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -423,19 +423,19 @@ function hexToBytes(hex: string): Uint8Array { const bad = new Uint16Array([0, 1, 99, 1, 3, 2]); expect(() => _native.DracoCodec.Encode( [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], - bad)).to.throw(/out of range/); + bad)).to.throw(); }); it("rejects a non-positive attribute size", function () { expect(() => _native.DracoCodec.Encode( [{ kind: "position", dracoName: "POSITION", data: positions, size: 0 }], - indices)).to.throw(/size/); + indices)).to.throw(); }); it("rejects attribute data that is not a multiple of its component count", function () { expect(() => _native.DracoCodec.Encode( [{ kind: "position", dracoName: "POSITION", data: new Float32Array(7), size: 3 }], - indices)).to.throw(/multiple/); + indices)).to.throw(); }); }); @@ -483,20 +483,20 @@ function hexToBytes(hex: string): Uint8Array { }); it("rejects a stride outside [1, 256]", function () { - expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES")).to.throw(/stride/); - expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES")).to.throw(/stride/); + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 0, "ATTRIBUTES")).to.throw(); + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 257, "ATTRIBUTES")).to.throw(); }); it("rejects an ATTRIBUTES stride that is not a multiple of 4", function () { - expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES")).to.throw(/multiple of 4/); + expect(() => _native.MeshoptCodec.Decode(ENCODED, COUNT, 6, "ATTRIBUTES")).to.throw(); }); it("rejects a negative count", function () { - expect(() => _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES")).to.throw(/count/); + expect(() => _native.MeshoptCodec.Decode(ENCODED, -1, STRIDE, "ATTRIBUTES")).to.throw(); }); it("rejects a TRIANGLES count that is not a multiple of 3", function () { - expect(() => _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES")).to.throw(/multiple of 3/); + expect(() => _native.MeshoptCodec.Decode(ENCODED, 4, 2, "TRIANGLES")).to.throw(); }); it("rejects a non-typed-array source", function () { From b4090d8daf995d3db4cd68eb0941e67bbbcad8b9 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 29 Jul 2026 09:18:33 -0700 Subject: [PATCH 09/10] Build Draco decode-only and restrict it to the glTF bitstream 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 --- .../JavaScript/dist/tests.javaScript.all.js | 117 +++++--- .../JavaScript/src/tests.javaScript.all.ts | 115 +++++--- Dependencies/CMakeLists.txt | 7 + Plugins/NativeDraco/Source/NativeDraco.cpp | 275 +----------------- 4 files changed, 169 insertions(+), 345 deletions(-) diff --git a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js index ce97150a5..a54b08775 100644 --- a/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js +++ b/Apps/UnitTests/JavaScript/dist/tests.javaScript.all.js @@ -28247,7 +28247,7 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var chai__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(/*! chai */ "../../node_modules/chai/index.js"); /* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(/*! @babylonjs/materials */ "@babylonjs/core"); /* harmony import */ var _babylonjs_core__WEBPACK_IMPORTED_MODULE_4___default = /*#__PURE__*/__webpack_require__.n(_babylonjs_core__WEBPACK_IMPORTED_MODULE_4__); - +function _createForOfIteratorHelper(r, e) {var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];if (!t) {if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) {t && (r = t);var _n = 0,F = function F() {};return { s: F, n: function n() {return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] };}, e: function e(r) {throw r;}, f: F };}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");}var o,a = !0,u = !1;return { s: function s() {t = t.call(r);}, n: function n() {var r = t.next();return a = r.done, r;}, e: function e(r) {u = !0, o = r;}, f: function f() {try {a || null == t.return || t.return();} finally {if (u) throw o;}} };}function _unsupportedIterableToArray(r, a) {if (r) {if ("string" == typeof r) return _arrayLikeToArray(r, a);var t = {}.toString.call(r).slice(8, -1);return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0;}}function _arrayLikeToArray(r, a) {(null == a || a > r.length) && (a = r.length);for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e];return n;} @@ -28582,24 +28582,48 @@ function hexToBytes(hex) { ); var indices = new Uint16Array([0, 1, 2, 1, 3, 2]); - function encodeFixture() { - return _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], - indices); - } + // Encoded by the reference draco3dgltf 1.5.7 encoder (the same package Babylon.js takes + // its decoder from), standard edgebreaker, 14-bit position quantization. Using a fixture + // from the reference encoder rather than our own output pins this decoder to the upstream + // bitstream instead of to itself. + var ENCODED = hexToBytes( + "445241434f02020101000000040200020000011fff011101ff00000100090300000201010100030301300110030024824a0400000000ff3f00000000000000000000000000000000803f0e"); + var POSITION_ATTRIBUTE_ID = 0; + + // A 63-vertex UV sphere carrying POSITION, NORMAL and TEX_COORD, encoded by the same + // reference encoder with per-attribute quantization. The single-triangle-pair fixture + // above cannot exercise multi-attribute decoding or a non-degenerate edgebreaker + // traversal, which are what the glTF-bitstream-only build actually restricts. + // + // The expected counts below are what the reference draco3dgltf decoder reports for this + // exact buffer, not the pre-encode mesh: Draco merges points whose attributes all match + // and drops the degenerate triangles at the poles, so 63 vertices / 96 triangles going + // in becomes 62 vertices / 91 triangles coming out. + var SPHERE = hexToBytes( + "445241434f020201010000003a5b025b05001a5fd73e55ad3e55d5aa3e5555adaa3ea55455559faaaaaa565501ff0111" + + "ff02694af8058097a3755f03ff0000000000010100010009030000020101090300010301030902000202010101000f2b" + + "a106b907592e51030c141534f4dfc29a78ddaf7f80bed2ffff2bad28fedf4fcb03010030a7577c44104633030047e86d" + + "00c02304008f08128a7a003c181d05009108c20010d48301102848f888fa0fdc030090d02b010050db9591d895901000" + + "749b07f388bca224060084f91f49f408cd0c0088ee9110006466480800ba0d840110a80703405010e61175c5500b00ba" + + "12a805005dc92b23d12b9dc1b6fbb400e0113dc2480c8007eb8a9208c2b43d000009bd1200f8b4a016007425510b00bc" + + "525702009fd62b010050eb950040a8ed4a0040d48a5a00d095422d00f04aa0160078a54f0b00ba92570200a2b62b0180" + + "50eb0cb89da8ed3600e0110200490409030580074762004098a00000e1aef8881e8cb7010090b0ed018047f8081f1100" + + "80b0001075ea0e00cc0c00000000ff3f0000000080bf000080bf000080bf000000400e000301000b036904135103f109" + + "a106b9270e3ecb87a50ecead84ceeaa8bdde65000002dc7542d24fdbb29af35e882de5dab6c48d3dd1bbeceebaec45c6" + + "73b0bb6b772244eb067057e873dc4e3d72c57b21b6539244f9b4fe44ef620ee3d4cf9108b00b40340076034034000000" + + "44130dc01c19673cc72e4413bd8b398c673cc72e00d10010057042d294b60000fc0fd127b6b63cbd9acf91f1ffff9492" + + "621f37ff030000ff0100000a010101000d039532550a1b0901090109010a0eb9fbab2e93abef3f8700fcff00aceaff00" + + "60a0001a8220220800400800820822089a0000000000ff0f000000000000000000000000803f0c"); + var SPHERE_VERTICES = 62; + var SPHERE_INDICES = 273; it("publishes the codec version it was built against", function () { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Version).to.be.a("string"); (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Version).to.match(/^\d+\.\d+\.\d+$/); }); - it("round trips a mesh through encode and decode", function () { - var encoded = encodeFixture(); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(encoded.data).to.be.instanceOf(Uint8Array); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(encoded.data.length).to.be.greaterThan(0); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(encoded.attributeIds).to.have.property("position"); - - var decoded = _native.DracoCodec.Decode(encoded.data, encoded.attributeIds); + it("decodes a mesh produced by the reference glTF encoder", function () { + var decoded = _native.DracoCodec.Decode(ENCODED, { position: POSITION_ATTRIBUTE_ID }); (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.totalVertices).to.equal(positions.length / 3); (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.indices.length).to.equal(indices.length); @@ -28626,6 +28650,45 @@ function hexToBytes(hex) { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(actualCorners.sort()).to.deep.equal(expectedCorners.sort()); }); + it("decodes without an explicit attribute id map", function () { + var decoded = _native.DracoCodec.Decode(ENCODED); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.totalVertices).to.equal(positions.length / 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.attributes.find(function (a) {return a.kind === "position";})).to.not.equal(undefined); + }); + + it("decodes a multi-attribute mesh", function () { + var decoded = _native.DracoCodec.Decode(SPHERE, { position: 0, normal: 1, uv: 2 }); + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.totalVertices).to.equal(SPHERE_VERTICES); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.indices.length).to.equal(SPHERE_INDICES); + + var byKind = {};var _iterator = _createForOfIteratorHelper( + decoded.attributes),_step;try {for (_iterator.s(); !(_step = _iterator.n()).done;) {var a = _step.value; + byKind[a.kind] = a; + }} catch (err) {_iterator.e(err);} finally {_iterator.f();} + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.position.size).to.equal(3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.normal.size).to.equal(3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.uv.size).to.equal(2); + + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.position.data.length).to.equal(SPHERE_VERTICES * 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.normal.data.length).to.equal(SPHERE_VERTICES * 3); + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(byKind.uv.data.length).to.equal(SPHERE_VERTICES * 2); + + // Every index must address a real vertex, and the geometry must actually be the + // unit sphere that was encoded rather than plausible-looking noise. + for (var i = 0; i < decoded.indices.length; ++i) { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(decoded.indices[i]).to.be.lessThan(SPHERE_VERTICES); + } + + for (var v = 0; v < SPHERE_VERTICES; ++v) { + var x = byKind.position.data[v * 3]; + var y = byKind.position.data[v * 3 + 1]; + var z = byKind.position.data[v * 3 + 2]; + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(Math.sqrt(x * x + y * y + z * z)).to.be.closeTo(1, 0.01); + } + }); + it("rejects malformed input", function () { var garbage = new Uint8Array(64); for (var i = 0; i < garbage.length; ++i) { @@ -28635,8 +28698,7 @@ function hexToBytes(hex) { }); it("rejects truncated input", function () { - var encoded = encodeFixture(); - var truncated = encoded.data.slice(0, Math.floor(encoded.data.length / 2)); + var truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(truncated);}).to.throw(); }); @@ -28644,29 +28706,8 @@ function hexToBytes(hex) { (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Decode(new Uint8Array(0));}).to.throw(); }); - it("rejects encoding without a position attribute", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( - [{ kind: "normal", dracoName: "NORMAL", data: positions, size: 3 }], - indices);}).to.throw(); - }); - - it("rejects an index that is out of range for the vertex count", function () { - var bad = new Uint16Array([0, 1, 99, 1, 3, 2]); - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], - bad);}).to.throw(); - }); - - it("rejects a non-positive attribute size", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: positions, size: 0 }], - indices);}).to.throw(); - }); - - it("rejects attribute data that is not a multiple of its component count", function () { - (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(function () {return _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: new Float32Array(7), size: 3 }], - indices);}).to.throw(); + it("does not expose an encoder", function () { + (0,chai__WEBPACK_IMPORTED_MODULE_3__.expect)(_native.DracoCodec.Encode).to.equal(undefined); }); }); diff --git a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts index f73d83d7c..40c05d006 100644 --- a/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts +++ b/Apps/UnitTests/JavaScript/src/tests.javaScript.all.ts @@ -351,24 +351,48 @@ function hexToBytes(hex: string): Uint8Array { ]); const indices = new Uint16Array([0, 1, 2, 1, 3, 2]); - function encodeFixture() { - return _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], - indices); - } + // Encoded by the reference draco3dgltf 1.5.7 encoder (the same package Babylon.js takes + // its decoder from), standard edgebreaker, 14-bit position quantization. Using a fixture + // from the reference encoder rather than our own output pins this decoder to the upstream + // bitstream instead of to itself. + const ENCODED = hexToBytes( + "445241434f02020101000000040200020000011fff011101ff00000100090300000201010100030301300110030024824a0400000000ff3f00000000000000000000000000000000803f0e"); + const POSITION_ATTRIBUTE_ID = 0; + + // A 63-vertex UV sphere carrying POSITION, NORMAL and TEX_COORD, encoded by the same + // reference encoder with per-attribute quantization. The single-triangle-pair fixture + // above cannot exercise multi-attribute decoding or a non-degenerate edgebreaker + // traversal, which are what the glTF-bitstream-only build actually restricts. + // + // The expected counts below are what the reference draco3dgltf decoder reports for this + // exact buffer, not the pre-encode mesh: Draco merges points whose attributes all match + // and drops the degenerate triangles at the poles, so 63 vertices / 96 triangles going + // in becomes 62 vertices / 91 triangles coming out. + const SPHERE = hexToBytes( + "445241434f020201010000003a5b025b05001a5fd73e55ad3e55d5aa3e5555adaa3ea55455559faaaaaa565501ff0111" + + "ff02694af8058097a3755f03ff0000000000010100010009030000020101090300010301030902000202010101000f2b" + + "a106b907592e51030c141534f4dfc29a78ddaf7f80bed2ffff2bad28fedf4fcb03010030a7577c44104633030047e86d" + + "00c02304008f08128a7a003c181d05009108c20010d48301102848f888fa0fdc030090d02b010050db9591d895901000" + + "749b07f388bca224060084f91f49f408cd0c0088ee9110006466480800ba0d840110a80703405010e61175c5500b00ba" + + "12a805005dc92b23d12b9dc1b6fbb400e0113dc2480c8007eb8a9208c2b43d000009bd1200f8b4a016007425510b00bc" + + "525702009fd62b010050eb950040a8ed4a0040d48a5a00d095422d00f04aa0160078a54f0b00ba92570200a2b62b0180" + + "50eb0cb89da8ed3600e0110200490409030580074762004098a00000e1aef8881e8cb7010090b0ed018047f8081f1100" + + "80b0001075ea0e00cc0c00000000ff3f0000000080bf000080bf000080bf000000400e000301000b036904135103f109" + + "a106b9270e3ecb87a50ecead84ceeaa8bdde65000002dc7542d24fdbb29af35e882de5dab6c48d3dd1bbeceebaec45c6" + + "73b0bb6b772244eb067057e873dc4e3d72c57b21b6539244f9b4fe44ef620ee3d4cf9108b00b40340076034034000000" + + "44130dc01c19673cc72e4413bd8b398c673cc72e00d10010057042d294b60000fc0fd127b6b63cbd9acf91f1ffff9492" + + "621f37ff030000ff0100000a010101000d039532550a1b0901090109010a0eb9fbab2e93abef3f8700fcff00aceaff00" + + "60a0001a8220220800400800820822089a0000000000ff0f000000000000000000000000803f0c"); + const SPHERE_VERTICES = 62; + const SPHERE_INDICES = 273; it("publishes the codec version it was built against", function () { expect(_native.DracoCodec.Version).to.be.a("string"); expect(_native.DracoCodec.Version).to.match(/^\d+\.\d+\.\d+$/); }); - it("round trips a mesh through encode and decode", function () { - const encoded = encodeFixture(); - expect(encoded.data).to.be.instanceOf(Uint8Array); - expect(encoded.data.length).to.be.greaterThan(0); - expect(encoded.attributeIds).to.have.property("position"); - - const decoded = _native.DracoCodec.Decode(encoded.data, encoded.attributeIds); + it("decodes a mesh produced by the reference glTF encoder", function () { + const decoded = _native.DracoCodec.Decode(ENCODED, { position: POSITION_ATTRIBUTE_ID }); expect(decoded.totalVertices).to.equal(positions.length / 3); expect(decoded.indices.length).to.equal(indices.length); @@ -395,6 +419,45 @@ function hexToBytes(hex: string): Uint8Array { expect(actualCorners.sort()).to.deep.equal(expectedCorners.sort()); }); + it("decodes without an explicit attribute id map", function () { + const decoded = _native.DracoCodec.Decode(ENCODED); + expect(decoded.totalVertices).to.equal(positions.length / 3); + expect(decoded.attributes.find((a: any) => a.kind === "position")).to.not.equal(undefined); + }); + + it("decodes a multi-attribute mesh", function () { + const decoded = _native.DracoCodec.Decode(SPHERE, { position: 0, normal: 1, uv: 2 }); + + expect(decoded.totalVertices).to.equal(SPHERE_VERTICES); + expect(decoded.indices.length).to.equal(SPHERE_INDICES); + + const byKind: any = {}; + for (const a of decoded.attributes) { + byKind[a.kind] = a; + } + + expect(byKind.position.size).to.equal(3); + expect(byKind.normal.size).to.equal(3); + expect(byKind.uv.size).to.equal(2); + + expect(byKind.position.data.length).to.equal(SPHERE_VERTICES * 3); + expect(byKind.normal.data.length).to.equal(SPHERE_VERTICES * 3); + expect(byKind.uv.data.length).to.equal(SPHERE_VERTICES * 2); + + // Every index must address a real vertex, and the geometry must actually be the + // unit sphere that was encoded rather than plausible-looking noise. + for (let i = 0; i < decoded.indices.length; ++i) { + expect(decoded.indices[i]).to.be.lessThan(SPHERE_VERTICES); + } + + for (let v = 0; v < SPHERE_VERTICES; ++v) { + const x = byKind.position.data[v * 3]; + const y = byKind.position.data[v * 3 + 1]; + const z = byKind.position.data[v * 3 + 2]; + expect(Math.sqrt(x * x + y * y + z * z)).to.be.closeTo(1, 0.01); + } + }); + it("rejects malformed input", function () { const garbage = new Uint8Array(64); for (let i = 0; i < garbage.length; ++i) { @@ -404,8 +467,7 @@ function hexToBytes(hex: string): Uint8Array { }); it("rejects truncated input", function () { - const encoded = encodeFixture(); - const truncated = encoded.data.slice(0, Math.floor(encoded.data.length / 2)); + const truncated = ENCODED.slice(0, Math.floor(ENCODED.length / 2)); expect(() => _native.DracoCodec.Decode(truncated)).to.throw(); }); @@ -413,29 +475,8 @@ function hexToBytes(hex: string): Uint8Array { expect(() => _native.DracoCodec.Decode(new Uint8Array(0))).to.throw(); }); - it("rejects encoding without a position attribute", function () { - expect(() => _native.DracoCodec.Encode( - [{ kind: "normal", dracoName: "NORMAL", data: positions, size: 3 }], - indices)).to.throw(); - }); - - it("rejects an index that is out of range for the vertex count", function () { - const bad = new Uint16Array([0, 1, 99, 1, 3, 2]); - expect(() => _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: positions, size: 3 }], - bad)).to.throw(); - }); - - it("rejects a non-positive attribute size", function () { - expect(() => _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: positions, size: 0 }], - indices)).to.throw(); - }); - - it("rejects attribute data that is not a multiple of its component count", function () { - expect(() => _native.DracoCodec.Encode( - [{ kind: "position", dracoName: "POSITION", data: new Float32Array(7), size: 3 }], - indices)).to.throw(); + it("does not expose an encoder", function () { + expect(_native.DracoCodec.Encode).to.equal(undefined); }); }); diff --git a/Dependencies/CMakeLists.txt b/Dependencies/CMakeLists.txt index a085cd610..9f7a5a6f4 100644 --- a/Dependencies/CMakeLists.txt +++ b/Dependencies/CMakeLists.txt @@ -196,6 +196,13 @@ if(BABYLON_NATIVE_PLUGIN_NATIVEDRACO AND NOT TARGET draco AND NOT TARGET draco_s set(DRACO_BUILD_EXECUTABLES OFF CACHE BOOL "" FORCE) set(DRACO_JS_GLUE OFF CACHE BOOL "" FORCE) + # Build only the features in the Draco glTF bitstream spec: mesh compression, normal + # encoding and the standard edgebreaker. This is the same subset Babylon.js decodes + # with, since its default configuration loads draco_decoder_gltf.wasm rather than the + # full decoder, so it costs no capability the JavaScript path has. It drops point cloud + # compression, the predictive edgebreaker and pre-glTF backwards compatibility. + set(DRACO_GLTF_BITSTREAM ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable_With_Message(draco) # The combined draco library target is named `draco` for MSVC and diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp index 1de1fc10c..c90ea3876 100644 --- a/Plugins/NativeDraco/Source/NativeDraco.cpp +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -4,9 +4,7 @@ #include #include -#include #include -#include #include #include #include @@ -212,273 +210,6 @@ namespace Babylon::Plugins return result; } - // ---------------------------------------------------------------------------- - // Encoder - // ---------------------------------------------------------------------------- - - draco::GeometryAttribute::Type DracoAttributeTypeFromName(const std::string& name) - { - if (name == "POSITION") return draco::GeometryAttribute::POSITION; - if (name == "NORMAL") return draco::GeometryAttribute::NORMAL; - if (name == "COLOR") return draco::GeometryAttribute::COLOR; - if (name == "TEX_COORD") return draco::GeometryAttribute::TEX_COORD; - return draco::GeometryAttribute::GENERIC; - } - - // Returns a typed pointer to the start of the typed array's data, honoring its byte offset. - template - const T* TypedArrayData(const Napi::TypedArray& array) - { - const auto* base = static_cast(array.ArrayBuffer().Data()) + array.ByteOffset(); - return reinterpret_cast(base); - } - - // Replicates draco's emscripten PointCloudBuilder::AddAttribute: creates a de-interleaved - // per-point attribute and returns its attribute id (which equals its unique id, see - // PointCloud::SetAttribute -> set_unique_id). - template - int AddAttributeToMesh(draco::Mesh& mesh, draco::GeometryAttribute::Type type, draco::DataType dataType, uint32_t numVertices, int8_t numComponents, const T* values) - { - std::unique_ptr att(new draco::PointAttribute()); - att->Init(type, numComponents, dataType, /* normalized */ false, numVertices); - const int attId = mesh.AddAttribute(std::move(att)); - draco::PointAttribute* attPtr = mesh.attribute(attId); - for (draco::PointIndex i(0); i < numVertices; ++i) - { - attPtr->SetAttributeValue(attPtr->mapped_index(i), &values[static_cast(i.value()) * numComponents]); - } - if (mesh.num_points() == 0) - { - mesh.set_num_points(numVertices); - } - else if (mesh.num_points() != numVertices) - { - return -1; - } - return attId; - } - - // Reads and validates an attribute's component count. draco stores the component count as - // an int8_t, and divides the element count by it, so a non-positive or oversized value would - // either divide by zero or silently truncate. - int8_t ReadAttributeSize(Napi::Env env, const Napi::Object& attr, const Napi::TypedArray& data) - { - const int32_t size = attr.Get("size").As().Int32Value(); - if (size <= 0 || size > std::numeric_limits::max()) - { - throw Napi::RangeError::New(env, "Draco: attribute 'size' must be in [1, 127], got " + std::to_string(size)); - } - if (data.ElementLength() % static_cast(size) != 0) - { - throw Napi::RangeError::New(env, "Draco: attribute data length (" + std::to_string(data.ElementLength()) + - ") is not a multiple of its component count (" + std::to_string(size) + ")."); - } - const size_t numVertices = data.ElementLength() / static_cast(size); - if (static_cast(numVertices) > 0xFFFFFFFFull) - { - throw Napi::RangeError::New(env, "Draco: attribute has too many vertices: " + std::to_string(numVertices)); - } - return static_cast(size); - } - - // Dispatches AddAttributeToMesh on the typed array's element type, mirroring the WASM - // encoder's addAttributeMap. - int AddTypedAttributeToMesh(Napi::Env env, draco::Mesh& mesh, draco::GeometryAttribute::Type type, const Napi::TypedArray& data, int8_t numComponents) - { - const uint32_t numVertices = static_cast(data.ElementLength() / static_cast(numComponents)); - switch (data.TypedArrayType()) - { - case napi_float32_array: return AddAttributeToMesh(mesh, type, draco::DT_FLOAT32, numVertices, numComponents, TypedArrayData(data)); - case napi_uint32_array: return AddAttributeToMesh(mesh, type, draco::DT_UINT32, numVertices, numComponents, TypedArrayData(data)); - case napi_uint16_array: return AddAttributeToMesh(mesh, type, draco::DT_UINT16, numVertices, numComponents, TypedArrayData(data)); - case napi_uint8_array: - case napi_uint8_clamped_array: return AddAttributeToMesh(mesh, type, draco::DT_UINT8, numVertices, numComponents, TypedArrayData(data)); - case napi_int32_array: return AddAttributeToMesh(mesh, type, draco::DT_INT32, numVertices, numComponents, TypedArrayData(data)); - case napi_int16_array: return AddAttributeToMesh(mesh, type, draco::DT_INT16, numVertices, numComponents, TypedArrayData(data)); - case napi_int8_array: return AddAttributeToMesh(mesh, type, draco::DT_INT8, numVertices, numComponents, TypedArrayData(data)); - default: - throw Napi::Error::New(env, "Draco: Unsupported attribute typed array for encoding"); - } - } - - // Reads an index typed array (Uint16Array or Uint32Array) into a flat int vector. - std::vector ReadIndices(Napi::Env env, const Napi::TypedArray& data) - { - const auto arrayType = data.TypedArrayType(); - if (arrayType != napi_uint32_array && arrayType != napi_uint16_array) - { - throw Napi::TypeError::New(env, "Draco: indices must be a Uint16Array or a Uint32Array."); - } - - const size_t count = data.ElementLength(); - if (count % 3 != 0) - { - throw Napi::RangeError::New(env, "Draco: index count (" + std::to_string(count) + ") is not a multiple of 3."); - } - if (static_cast(count / 3) > 0xFFFFFFFFull) - { - throw Napi::RangeError::New(env, "Draco: too many faces: " + std::to_string(count / 3)); - } - - std::vector out(count); - if (arrayType == napi_uint32_array) - { - const uint32_t* src = TypedArrayData(data); - for (size_t i = 0; i < count; ++i) { out[i] = static_cast(src[i]); } - } - else - { - const uint16_t* src = TypedArrayData(data); - for (size_t i = 0; i < count; ++i) { out[i] = static_cast(src[i]); } - } - return out; - } - - Napi::Value EncodeDracoMesh(const Napi::CallbackInfo& info) - { - auto env = info.Env(); - - if (info.Length() < 1 || !info[0].IsArray()) - { - throw Napi::TypeError::New(env, "encodeDracoMesh: expected an array of attributes"); - } - - const auto attributesIn = info[0].As(); - const auto options = (info.Length() > 2 && info[2].IsObject()) ? info[2].As() : Napi::Object::New(env); - - // Locate the mandatory position attribute and its vertex count. - uint32_t positionVerticesCount = 0; - bool hasPosition = false; - for (uint32_t i = 0; i < attributesIn.Length(); ++i) - { - const auto attr = attributesIn.Get(i).As(); - if (attr.Get("dracoName").As().Utf8Value() == "POSITION") - { - const auto data = attr.Get("data").As(); - const int8_t size = ReadAttributeSize(env, attr, data); - positionVerticesCount = static_cast(data.ElementLength() / static_cast(size)); - hasPosition = true; - break; - } - } - if (!hasPosition) - { - throw Napi::Error::New(env, "Draco: Missing position attribute for encoding."); - } - - // Indices: use the provided buffer, or synthesize an identity list for unindexed meshes. - std::vector indices; - if (info.Length() > 1 && info[1].IsTypedArray()) - { - indices = ReadIndices(env, info[1].As()); - - // Every index addresses a point in the position attribute; draco would otherwise - // read out of bounds when building the corner table. - for (const int index : indices) - { - if (index < 0 || static_cast(index) >= positionVerticesCount) - { - throw Napi::RangeError::New(env, "Draco: index " + std::to_string(index) + - " is out of range for " + std::to_string(positionVerticesCount) + " vertices."); - } - } - } - else - { - if (positionVerticesCount % 3 != 0) - { - throw Napi::RangeError::New(env, "Draco: unindexed meshes need a vertex count that is a multiple of 3, got " + - std::to_string(positionVerticesCount) + "."); - } - indices.resize(positionVerticesCount); - for (uint32_t i = 0; i < positionVerticesCount; ++i) { indices[i] = static_cast(i); } - } - - draco::Mesh mesh; - const uint32_t numFaces = static_cast(indices.size() / 3); - mesh.SetNumFaces(numFaces); - for (draco::FaceIndex f(0); f < numFaces; ++f) - { - draco::Mesh::Face face; - face[0] = draco::PointIndex(indices[f.value() * 3 + 0]); - face[1] = draco::PointIndex(indices[f.value() * 3 + 1]); - face[2] = draco::PointIndex(indices[f.value() * 3 + 2]); - mesh.SetFace(f, face); - } - - draco::Encoder encoder; - - const bool hasQuantization = options.Has("quantizationBits") && options.Get("quantizationBits").IsObject(); - const auto quantizationBits = hasQuantization ? options.Get("quantizationBits").As() : Napi::Object::New(env); - - auto attributeIds = Napi::Object::New(env); - for (uint32_t i = 0; i < attributesIn.Length(); ++i) - { - const auto attr = attributesIn.Get(i).As(); - const std::string kind = attr.Get("kind").As().Utf8Value(); - const std::string dracoName = attr.Get("dracoName").As().Utf8Value(); - const auto data = attr.Get("data").As(); - const int8_t size = ReadAttributeSize(env, attr, data); - const draco::GeometryAttribute::Type type = DracoAttributeTypeFromName(dracoName); - - const int attId = AddTypedAttributeToMesh(env, mesh, type, data, size); - if (attId < 0) - { - throw Napi::Error::New(env, "Draco: Failed to add attribute '" + kind + "' (vertex count mismatch)."); - } - attributeIds.Set(kind, Napi::Number::New(env, attId)); - - if (hasQuantization && quantizationBits.Has(dracoName)) - { - const int32_t bits = quantizationBits.Get(dracoName).As().Int32Value(); - if (bits) // matches WASM path: only set for truthy (non-zero) values - { - encoder.SetAttributeQuantization(type, bits); - } - } - } - - if (options.Has("method") && options.Get("method").IsString()) - { - const std::string method = options.Get("method").As().Utf8Value(); - encoder.SetEncodingMethod(method == "MESH_SEQUENTIAL_ENCODING" ? draco::MESH_SEQUENTIAL_ENCODING : draco::MESH_EDGEBREAKER_ENCODING); - } - - if (options.Has("encodeSpeed") && options.Get("encodeSpeed").IsNumber() && options.Has("decodeSpeed") && options.Get("decodeSpeed").IsNumber()) - { - encoder.SetSpeedOptions(options.Get("encodeSpeed").As().Int32Value(), options.Get("decodeSpeed").As().Int32Value()); - } - - // Mirror Encoder::EncodeMeshToDracoBuffer. - if (mesh.GetNamedAttributeId(draco::GeometryAttribute::POSITION) == -1) - { - throw Napi::Error::New(env, "Draco: Missing position attribute for encoding."); - } - if (!mesh.DeduplicateAttributeValues()) - { - throw Napi::Error::New(env, "Draco: Failed to deduplicate attribute values."); - } - mesh.DeduplicatePointIds(); - - draco::EncoderBuffer buffer; - const draco::Status status = encoder.EncodeMeshToBuffer(mesh, &buffer); - if (!status.ok()) - { - throw Napi::Error::New(env, std::string("Draco: Failed to encode: ") + status.error_msg()); - } - - // Uint8Array, not Int8Array: this is opaque binary output, and every other binary - // value crossing this boundary (MeshoptCodec.Decode, the ImageBitmap paths) is - // unsigned. Int8Array would surface every byte above 127 as a negative number to - // callers that index it directly. - auto encodedData = Napi::Uint8Array::New(env, buffer.size()); - std::memcpy(encodedData.Data(), buffer.data(), buffer.size()); - - auto result = Napi::Object::New(env); - result.Set("data", encodedData); - result.Set("attributeIds", attributeIds); - return result; - } } } @@ -493,9 +224,13 @@ namespace Babylon::Plugins::NativeDraco // the version of the codec built into this binary. A bare function name cannot express // that, and Draco's bitstream is versioned, so a caller holding a stream this build is // too old to read has no other way to find out ahead of time. + // + // Decode only, mirroring Babylon.js: its default configuration loads + // draco_decoder_gltf.wasm, and the encoder is a separate module fetched on demand by + // DracoEncoder. Linking the encoder here would add ~1.2 MB to every binary to serve an + // authoring path that Babylon Native does not exercise. auto codec = Napi::Object::New(env); codec.Set("Decode", Napi::Function::New(env, DecodeDracoMesh, "Decode")); - codec.Set("Encode", Napi::Function::New(env, EncodeDracoMesh, "Encode")); codec.Set("Version", Napi::String::New(env, draco::kDracoVersion)); native.Set("DracoCodec", codec); } From 531db2f78220df28ab32de88121b1418dd2436d2 Mon Sep 17 00:00:00 2001 From: Branimir Karadzic Date: Wed, 29 Jul 2026 16:16:46 -0700 Subject: [PATCH 10/10] Add plugin READMEs, extend CI coverage, reference the JsRuntimeHost issue Addresses review feedback on #1797. CI coverage: only build-linux, build-macos and build-win32 passed the plugin options, so build-android, build-ios and build-uwp stopped compiling either plugin once they were defaulted to OFF - dropping exactly the NDK, iOS and WindowsStore toolchains where a new third-party C++ dependency is most likely to break. Turn both on in those three workflows. Android goes through Gradle, so BabylonNative/build.gradle now forwards the two options to CMake when the caller supplies them, leaving the default for local Android builds unchanged. READMEs: add Plugins/NativeDraco/README.md and Plugins/NativeMeshopt/README.md modelled on Plugins/NativeEncoding/README.md, including its experimental banner. Both record that the plugins are off by default, that NativeDraco is decode-only and restricted to the glTF bitstream (no point clouds, no predictive edgebreaker), the JS interface, and that nothing in the pinned babylonjs package calls _native.DracoCodec or _native.MeshoptCodec yet, so the grouping and names have not faced a real consumer. GetPropertyNames workaround: filed as BabylonJS/JsRuntimeHost#216 and referenced from the comment so the helper can be removed when it is fixed. The issue records that the fix is more than JavaScriptCore's missing argument: Node-API specifies the enumerable properties including the prototype chain, which is what V8 returns, while getOwnPropertyNames is own-only and includes non-enumerables. Verified against all four backends in JsRuntimeHost - Chakra (JsGetOwnPropertyNames) matches neither axis, QuickJS (JS_GPN_ENUM_ONLY) filters enumerability but omits the prototype chain - so it is a conformance gap across all three non-V8 backends. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 60c2ec68-6de1-445d-9fc9-b699db737eae --- .github/workflows/build-android.yml | 2 + .github/workflows/build-ios.yml | 2 + .github/workflows/build-uwp.yml | 2 + .../Android/BabylonNative/build.gradle | 9 ++++ Plugins/NativeDraco/README.md | 51 +++++++++++++++++++ Plugins/NativeDraco/Source/NativeDraco.cpp | 13 ++++- Plugins/NativeMeshopt/README.md | 42 +++++++++++++++ 7 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 Plugins/NativeDraco/README.md create mode 100644 Plugins/NativeMeshopt/README.md diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 1a04d5ee3..4e55fd855 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -47,4 +47,6 @@ jobs: -PjsEngine=${{ inputs.js-engine }} \ -PARM64Only \ -PNDK_VERSION=${{ env.NDK_VERSION }} \ + -PBABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -PBABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ -PSANITIZERS=OFF diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 49d22f804..e48cbb110 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -37,6 +37,8 @@ jobs: -D IOS=ON \ -D DEPLOYMENT_TARGET=${{ inputs.deployment-target }} \ -D BABYLON_DEBUG_TRACE=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON \ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON \ -D CMAKE_IOS_INSTALL_COMBINED=NO - name: Build Playground iOS diff --git a/.github/workflows/build-uwp.yml b/.github/workflows/build-uwp.yml index d0699cc37..b307f9e46 100644 --- a/.github/workflows/build-uwp.yml +++ b/.github/workflows/build-uwp.yml @@ -45,6 +45,8 @@ jobs: -D CMAKE_SYSTEM_VERSION=10.0 ^ ${{ steps.napi.outputs.define }} ^ -A ${{ inputs.platform }} ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON ^ + -D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON ^ -D BABYLON_DEBUG_TRACE=ON - name: Build UWP diff --git a/Apps/Playground/Android/BabylonNative/build.gradle b/Apps/Playground/Android/BabylonNative/build.gradle index 3f075645d..439f309b8 100644 --- a/Apps/Playground/Android/BabylonNative/build.gradle +++ b/Apps/Playground/Android/BabylonNative/build.gradle @@ -37,6 +37,15 @@ if (project.hasProperty("importHostCompilers")) { cmakeArguments.add("-DIMPORT_HOST_COMPILERS=${project.property('importHostCompilers')}") } +// The experimental codec plugins default to OFF in CMake. Allow the caller (CI) to +// turn them on so the NDK toolchain gets build coverage for their third-party +// dependencies, without changing the default for local Android builds. +["BABYLON_NATIVE_PLUGIN_NATIVEDRACO", "BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT"].each { option -> + if (project.hasProperty(option)) { + cmakeArguments.add("-D${option}=${project.property(option)}") + } +} + configurations { natives } android { diff --git a/Plugins/NativeDraco/README.md b/Plugins/NativeDraco/README.md new file mode 100644 index 000000000..aeb092c8d --- /dev/null +++ b/Plugins/NativeDraco/README.md @@ -0,0 +1,51 @@ +# NativeDraco + +> ⚠️ **This plugin is experimental and subject to change.** + +The NativeDraco plugin provides native [Draco](https://github.com/google/draco) geometry decompression to Babylon, so `KHR_draco_mesh_compression` glTF assets can be decoded without shipping and instantiating the Draco WebAssembly module. + +The plugin is **off by default**. Enable it with `-D BABYLON_NATIVE_PLUGIN_NATIVEDRACO=ON`. + +## Limitations + +- **Decode only.** There is no encoder. This mirrors Babylon.js, whose default configuration loads `draco_decoder_gltf.wasm`; its encoder lives in a separate module (`draco_encoder.wasm`) that `DracoEncoder` fetches on demand. Linking Draco's encoder here would add roughly 1.2 MB to every binary to serve an authoring path Babylon Native does not exercise. +- **glTF bitstream only.** Draco is built with `DRACO_GLTF_BITSTREAM=ON`, matching the `draco3dgltf` package Babylon.js decodes with. That enables mesh compression, normal encoding and the standard edgebreaker, and excludes point clouds, the predictive edgebreaker, backwards compatibility with pre-1.0 streams, and attribute deduplication. A point-cloud stream decodes to an error rather than geometry. +- **No consumer yet.** Nothing in the pinned `babylonjs` package calls `_native.DracoCodec`. The grouping and the entry-point names have therefore not faced a real consumer and may still move. + +## Design + +The API is exposed as a single `DracoCodec` object on the `_native` global rather than as free functions, so that: + +1. **One feature probe.** JavaScript checks for the object once instead of once per entry point. +2. **The object can carry a version.** Draco's bitstream is versioned; a caller holding a stream this build is too old to read otherwise has no way to find out ahead of time. A bare function name cannot express that. + +```typescript +interface INative { + DracoCodec: { + Decode: ( + data: ArrayBufferView, + attributeIds?: { [kind: string]: number } + ) => { + indices: Uint32Array | null; + attributes: Array<{ + kind: string; + data: Float32Array | Int8Array | Uint8Array | Int16Array | Uint16Array | Int32Array | Uint32Array; + size: number; + byteOffset: number; + byteStride: number; + normalized: boolean; + }>; + totalVertices: number; + }; + Version: string; + }; +} +``` + +`attributeIds` is the glTF path: a map of Babylon vertex-buffer kind to Draco unique attribute id, taken from the `KHR_draco_mesh_compression` extension. When it is omitted, attributes are identified by their Draco geometry type instead. + +`indices` is `null` for geometry that decodes to a point cloud rather than a triangular mesh. + +## Notes + +`Decode` reads the keys of `attributeIds` via `Object.keys` obtained from the global object, rather than `Napi::Object::GetPropertyNames()`. `GetPropertyNames()` is unusable on JavaScriptCore — the default engine on macOS and iOS — and the enumeration semantics differ across the non-V8 backends generally. See the comment in `Source/NativeDraco.cpp` and [JsRuntimeHost#216](https://github.com/BabylonJS/JsRuntimeHost/issues/216). diff --git a/Plugins/NativeDraco/Source/NativeDraco.cpp b/Plugins/NativeDraco/Source/NativeDraco.cpp index c90ea3876..eb1b21c88 100644 --- a/Plugins/NativeDraco/Source/NativeDraco.cpp +++ b/Plugins/NativeDraco/Source/NativeDraco.cpp @@ -28,8 +28,17 @@ namespace Babylon::Plugins // backend implements napi_get_property_names by calling Object.getOwnPropertyNames with // an argument count of zero, so the object under inspection is never passed and the call // evaluates getOwnPropertyNames(undefined), which throws. That makes GetPropertyNames - // unusable on JavaScriptCore, which is the default engine on macOS and iOS. Calling - // Object.keys through the global object goes through napi_call_function with the + // unusable on JavaScriptCore, which is the default engine on macOS and iOS. + // + // Supplying the missing argument would not be enough for a general fix: Node-API + // specifies the enumerable properties including the prototype chain (what V8 returns), + // whereas getOwnPropertyNames is own-only and includes non-enumerables. Chakra has the + // same enumerability mismatch and QuickJS omits the prototype chain, so this is a + // conformance gap across all three non-V8 backends. + // Tracked by https://github.com/BabylonJS/JsRuntimeHost/issues/216 - remove this helper + // in favour of GetPropertyNames() once that is fixed. + // + // Calling Object.keys through the global object goes through napi_call_function with the // argument actually supplied, and behaves identically on every engine for the plain data // objects this map is built from. Napi::Array OwnPropertyNames(Napi::Env env, const Napi::Object& object) diff --git a/Plugins/NativeMeshopt/README.md b/Plugins/NativeMeshopt/README.md new file mode 100644 index 000000000..1f7786467 --- /dev/null +++ b/Plugins/NativeMeshopt/README.md @@ -0,0 +1,42 @@ +# NativeMeshopt + +> ⚠️ **This plugin is experimental and subject to change.** + +The NativeMeshopt plugin provides native [meshoptimizer](https://github.com/zeux/meshoptimizer) vertex and index buffer decompression to Babylon, so `EXT_meshopt_compression` glTF assets can be decoded without shipping and instantiating the meshoptimizer WebAssembly module. + +The plugin is **off by default**. Enable it with `-D BABYLON_NATIVE_PLUGIN_NATIVEMESHOPT=ON`. + +## Limitations + +- **Decode only.** Encoding is an authoring-time concern that Babylon Native does not exercise. +- **No consumer yet.** Nothing in the pinned `babylonjs` package calls `_native.MeshoptCodec`. The grouping and the entry-point names have therefore not faced a real consumer and may still move. + +## Design + +The API is exposed as a single `MeshoptCodec` object on the `_native` global, for the same reasons as `DracoCodec`. Publishing the version matters more here: meshoptimizer stores its codec version in the first header byte, and a decoder **rejects** streams newer than it understands — returning an error rather than degraded output. Exposing the version lets the JavaScript side fall back before it tries. + +```typescript +interface INative { + MeshoptCodec: { + Decode: ( + source: ArrayBufferView, + count: number, + stride: number, + mode: "ATTRIBUTES" | "TRIANGLES" | "INDICES", + filter?: "NONE" | "OCTAHEDRAL" | "QUATERNION" | "EXPONENTIAL" + ) => Uint8Array; + Version: string; + }; +} +``` + +`Decode` mirrors the reference `meshopt_decoder.js` `decode()` helper exactly: it decodes into a buffer sized for `count` rounded up to a multiple of 4, applies the filter in place over that rounded count, then returns the first `count * stride` bytes. + +## Notes + +meshoptimizer validates `count` and `stride` with `assert()`, which compiles out in release builds — out-of-range values would be undefined behavior rather than a thrown error. The plugin therefore range-checks both before calling into the library: + +- `count` must not be negative. +- `stride` must be in `[1, 256]`. +- `ATTRIBUTES` requires a stride that is a multiple of 4. +- `TRIANGLES` and `INDICES` require a stride of exactly 2 or 4.