Skip to content

Monorepo - #1798

Draft
CedricGuillemet wants to merge 9 commits into
BabylonJS:masterfrom
CedricGuillemet:monorepo
Draft

Monorepo#1798
CedricGuillemet wants to merge 9 commits into
BabylonJS:masterfrom
CedricGuillemet:monorepo

Conversation

@CedricGuillemet

@CedricGuillemet CedricGuillemet commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Babylon Native currently assembles itself at configure time: FetchContent clones ~10 pinned repositories, including all of JsRuntimeHost. This PR vendors those sources into the tree and rewires CMake to add_subdirectory them, so a clone is a complete, buildable, reviewable snapshot.

What moved in-tree

Was fetched Now lives at
JsRuntimeHost Core/{Node-API,Node-API-JSI,Node-API-Extensions,Foundation,JsRuntime,AppRuntime,ScriptLoader} and Polyfills/{Console,Scheduling,XMLHttpRequest,Fetch,URL,AbortController,WebSocket,Blob,File,Performance,TextDecoder,TextEncoder}
bgfx.cmake, glslang, SPIRV-Cross, base-n, ios-cmake, AndroidExtensions, UrlLib Dependencies/
CMakeExtensions Scripts/extensions.cmake

Still fetched on demand, because they're large and only needed for non-default configurations: arcana.cpp, arcore-android-sdk, googletest, plus the engine-specific quickjs-ng, hermes, and asio/llhttp (V8 inspector).

Unit tests merged. Apps/UnitTests and JsRuntimeHost's Tests/UnitTests are now one project. The JsRuntimeHost suites are the base and run on every platform — including the iOS simulator and the Android emulator, which is new for Babylon Native. The Babylon Native graphics suites are layered on top via BABYLON_NATIVE_UNIT_TESTS_GRAPHICS, on by default for Win32/macOS/Linux and unavailable elsewhere.

CI. The existing Android_Ubuntu_* jobs now also run the tests on an emulator, and the iOS job now builds and runs them on a simulator. No new jobs.

No public API, behaviour or dependency version changes. Everything is pinned at the same commits it was fetched from.


Implementation notes

Vendoring

Each dependency was copied at exactly the commit it was pinned to on master, then its FetchContent_Declare deleted and FetchContent_MakeAvailable_With_Message(X) replaced with add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/X):

Dependency Pin
JsRuntimeHost 8cd142951018de07c89c23f726fb7dc9c8374599
bgfx.cmake 6c5515826c428337b7cbc0a5a7cc6d12325ce72b
glslang 284e4301e5a6b44b279635276588db7cdd942624
SPIRV-Cross a512817ddbcd879a3929aef7d1d762871bdf8635
AndroidExtensions 2e85a8d43b89246c460112c9e5546ad54b6e87b4
base-n 7573e77c0b9b0e8a5fb63d96dbde212c921993b4
CMakeExtensions 631780e42886e5f12bfd1a5568c7395f1d657f43
ios-cmake 4.5.0

metal-cpp was previously fetched as its own repository and turned into a single header at configure time with its MakeSingleHeader.py. It is not vendored: bgfx already ships a pre-generated single-header copy at bgfx/3rdparty/metal-cpp/metal.hpp and compiles its implementation into its Metal renderer, so Dependencies/CMakeLists.txt just copies that header into the build tree as Metal/Metal.hpp and exposes it through the header-only metal-cpp INTERFACE target. This keeps declarations and implementation at the same metal-cpp version and removes the configure-time Python dependency.

ios-cmake is special: it is consumed as a toolchain file before project(), so instead of a subdirectory it sets ios-cmake_SOURCE_DIR directly and keeps feeding CMAKE_TOOLCHAIN_FILE.

Scripts/extensions.cmake (ex-CMakeExtensions) is include()d from the root before anything else — it defines warnings_as_errors, disable_warnings, enable_objc_arc, download_nuget, set_cpu_platform_arch, npm and FetchContent_MakeAvailable_With_Message.

Pitfalls hit while doing this

These cost the most time; they're the non-obvious part of the work.

  1. JsRuntimeHost's root CMakeLists has to be merged too, not just its subdirectories. Copying Core/ and Polyfills/ leaves every JSRUNTIMEHOST_* option undefined, so if(JSRUNTIMEHOST_POLYFILL_FETCH) add_subdirectory(Fetch) silently does nothing. This is invisible: target_link_libraries(X PRIVATE Fetch) with no Fetch target is not a CMake error — it's treated as a plain -lFetch — so the failure only surfaces as a missing header hundreds of lines into the build. All JSRUNTIMEHOST_* options, plus NAPI_BUILD_ABI and the engine-dependency block, were ported into the root.
  2. ${CMAKE_SOURCE_DIR} is unusable here. Android adds Babylon Native as a subdirectory of a Gradle project, and Install/Test is its own source root. Every reference to ${CMAKE_SOURCE_DIR}/Scripts/extensions.cmake had to become ${CMAKE_CURRENT_LIST_DIR}-relative. Likewise PROJECT_IS_TOP_LEVEL evaluated before a sub-project's own project() call reflects the enclosing project; use CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR.
  3. NAPI_JAVASCRIPT_ENGINE must be resolved in the root, before add_subdirectory(Dependencies). The engine choice determines which dependencies are needed (QuickJS, Hermes, asio+llhttp). The per-platform defaults are duplicated in Core/Node-API/CMakeLists.txt so that directory still configures standalone; set(... CACHE ...) makes the second one a no-op.
  4. FetchContent's <name>_SOURCE_DIR is directory-scoped. Upstream JsRuntimeHost fetched Hermes from its root so the variable propagated down. Here Dependencies/ fetches it but Core/Node-API and Core/AppRuntime/V8Inspector consume it, so hermes_SOURCE_DIR and llhttp_SOURCE_DIR are re-published as CACHE INTERNAL.
  5. The vendored bgfx had been trimmed to 184 of 5113 files, dropping 3rdparty/{metal-cpp,khronos,directx-headers} — invisible on Windows/D3D11, fatal on macOS/iOS. Restored from bgfx@28a384fd. (3rdparty/metal-cpp is also what the metal-cpp target now consumes, so this restoration is load-bearing on Apple.)
  6. Hermes on Android needs two things upstream gets for free. HERMES_UNICODE_LITE=ON (Hermes only skips its ICU lookup for APPLE OR EMSCRIPTEN OR HERMES_IS_ANDROID OR HERMES_UNICODE_LITE, and HERMES_IS_ANDROID belongs to Hermes' React Native packaging), and a host-compiler bootstrap: hermesc/shermes are host tools run at build time, so when cross-compiling the in-tree targets are built for the wrong architecture. Dependencies/CMakeLists.txt configures and builds them into Build/HermesHostTools at configure time and passes IMPORT_HOST_COMPILERS. Also HERMES_ENABLE_TOOLS must stay ON (Hermes unconditionally adds external/node-api-cts when HERMES_ENABLE_NAPI is on, and those reference $<TARGET_FILE:hermes>), and BOOST_CONTEXT_IMPLEMENTATION=winfib on Windows (the vendored assembler backend breaks under MSBuild).
  7. CMake 4.x + cmake -E env no longer resolves a relative program name against WORKING_DIRECTORY — only PATH is searched. Apps/HeadlessScreenshotApp was relying on this for DirectXTK's fxc.exe. It now populates DirectXTK without configuring (SOURCE_SUBDIR DoNotConfigure), compiles the shaders at configure time with an explicitly located fxc.exe, then adds the subdirectory with USE_PREBUILT_SHADERS.

Unit test merge

Apps/UnitTests builds on every platform except Android and visionOS. Layout:

  • Base (always): Source/Tests.JsRuntime.cpp (JsRuntimeHost's Tests/UnitTests/Shared/Shared.cpp), JavaScript/{src,dist}/tests.jsRuntime.* (its Scripts/tests.ts), Assets/{sample.json,Halo_Believe.ply}, Scripts/symlink_target.js, and the polyfill/AppRuntime/gtest link set.
  • BABYLON_NATIVE_UNIT_TESTS_GRAPHICS: the Babylon Native suites, the graphics libraries and the babylon.js assets. Default ON for (WIN32 AND NOT WINDOWS_STORE) OR (APPLE AND NOT IOS AND NOT VISIONOS) OR (UNIX AND NOT ANDROID AND NOT APPLE), else OFF.

Three edits were needed to the imported test source: TEST(JavaScript, All)TEST(JsRuntime, All) (Babylon Native already registers JavaScript.All), the script path → app:///Assets/tests.jsRuntime.js, and int RunTests() dropped.

Hosts: graphics-on uses the existing Source/App.cpp + App.{Win32.cpp,Apple.mm,X11.cpp}, which create a window and a Graphics::Configuration. Graphics-off uses a single Source/App.JsRuntime.cpp that just calls InitGoogleTest/RUN_ALL_TESTS (with #ifdefs for the trace sink and iOS's exit-code-on-stderr). Android has no main()Android/app/src/main/cpp/JNI.cpp does it.

Two real bugs surfaced:

  • ARCANA_TEST_HOOKS must be a global compile definition, not a target one. AppRuntime.DestroyDoesNotDeadlock drives arcana's blocking_concurrent_queue to reproduce a shutdown race. That queue is header-only and instantiated inside Core/AppRuntime's translation unit, so defining the hook only on the test target produces a queue that never calls it and the test hangs forever. add_compile_definitions(ARCANA_TEST_HOOKS) now sits in the root under BABYLON_NATIVE_BUILD_APPS OR JSRUNTIMEHOST_TESTS. The Android target needs its own target_compile_definitions as well, because add_compile_definitions in Babylon Native's root scope doesn't propagate up to the Gradle project's cpp/ directory. (Related artifact: when this test timed out it detached its thread, which then crashed the next test, NodeApi.CreateDataViewRejectsOverflowingRange, with 0xC0000005. Both pass in isolation — don't chase the second failure.)
  • AndroidExtensions/StdoutLogger::Stop() double-closed the pipe read ends already owned by the reader thread's fdopen'd FILE*, so fdsan aborted the process after every test had passed. It now closes only the write ends; getline then returns −1 and the thread's own fclose releases the read end. NDK 28 enforces fdsan more strictly than the NDK 23 upstream uses, which is why JsRuntimeHost never saw it.

Other details worth knowing: googletest is fetched in Dependencies/CMakeLists.txt for all non-Android, non-visionOS platforms, and separately by Apps/UnitTests/Android/app/src/main/cpp/CMakeLists.txt for the Gradle build. The add_custom_command(OUTPUT … MAIN_DEPENDENCY <src>) asset-copy idiom only works if <src> is also listed in add_executable. The symlink-resolution tests need symlink_1.jssymlink_target.js and symlink_2.jssymlink_1.js, which can't be committed, so they're created as a POST_BUILD step; the JS side skips them unless hostPlatform is macOS/Unix/Win32.

CI

  • build-android.yml: after the existing Playground build, the four Android_Ubuntu_* jobs now enable KVM and run connectedAndroidTest on an api-33 / x86_64 / google_apis emulator with -no-window -gpu swiftshader_indirect. Guarded by runner.os == 'Linux', so the three Android_MacOS_* jobs are unaffected. Timeout 90 min on ubuntu. Hermes additionally frees disk space first — the host-compiler bootstrap plus NDK output plus an emulator image exhausts the default runner disk.
  • build-ios.yml: new simulator input; builds the UnitTests scheme for iphonesimulator and runs it via simctl install + simctl launch --console, recovering the exit code from stderr (simctl doesn't propagate it).
  • UWP needs no change — cmake --build already builds every target, so UnitTests gets compiled (not run), matching JsRuntimeHost.

Verified

Win32 desktop graphics-on: 23 tests / 11 suites pass. Win32 graphics-off and UWP x64: 6 tests / 4 suites pass. Android emulator (api-33 x86_64 swiftshader, V8): 6 suites pass. Playground validation tests pass on Windows/D3D11.

Not done

Babylon Native graphics tests on the Android emulator. Feasible — no Activity is required, since WindowT is an ANativeWindow* obtainable from ImageReader.getSurface(), and headless emulators fall back to swiftshader_indirect which provides GLES 3.0 — but slow, and MSAA/external-texture/device-loss cases would likely be flaky.

@CedricGuillemet

Copy link
Copy Markdown
Collaborator Author

Build time: monorepo vs master

Since this PR changes how the build is assembled, here is a timing comparison against master.

Baseline is run 30320546916master at 675b7671, which is the exact merge base of this branch — plus 4 to 9 additional master runs to average out runner noise. Monorepo samples come from runs 30347729040, 30359819577 and 30363255899.

Total CI wall time (sum of all 32 jobs)

Master Monorepo Δ
Sum of job durations 324.6 min 284.2 min −40.4 min (−12%)

Per phase

Phase Master (mean, range, n) Monorepo (mean, range, n) Δ
Checkout (Win32) 7.4s (7–10, n=5) 11.7s (11–13, n=3) +4s
Checkout (Ubuntu / macOS) 3–8s 4–7s ~0
Generate solution (Win32) 124.8s (101–152, n=5) 66.7s (62–70, n=3) −47%
Generate solution (macOS) 127.0s (86–211, n=10) 69.7s (53–83, n=3) −45%
Compile (Win32 Build) 250.2s (185–279, n=5) 233.7s (196–258, n=3) −7% (noise)
Configure + compile (Ubuntu Build X11) 226.4s (195–248, n=5) 193.7s (162–213, n=3) −14%
Compile (macOS Build Playground) 189.1s (132–270, n=10) 225.7s (218–232, n=3) +19% ⚠️

Reading

Comparable, and net faster. Configure time roughly halves, which is what you would expect: FetchContent no longer performs ~9 clones over the network at configure time. The variance collapses as well — Win32 generate went from a 101–152s spread down to 62–70s — because the step no longer depends on GitHub network weather. Compile time is unchanged, as it should be, since the sources and flags are the same.

Checkout costs about 4s more on Windows and nothing measurable elsewhere. That is far less than the tree growth (~1.85M lines) would suggest, because actions/checkout fetches a single shallow packed commit, and that content was being downloaded during configure anyway.

One watch item. macOS Build Playground averaged 226s across 3 monorepo runs versus 189s across 10 master runs. It sits inside master's own 132–270s range, and there is no settings regression behind it (no UNITY_BUILD is set anywhere in the tree, so nothing changed there). Still, 3 of 3 samples landing above the master mean is worth re-checking once more runs accumulate, rather than treating it as confirmed either way.

Not included above: the new test steps this PR adds are extra cost on top of the baseline — the Android emulator run is ~321s per Ubuntu job (×4 engines), and iOS gains a UnitTests build plus a simulator run. That is new coverage rather than a regression, and it is why the Android and iOS jobs are longer in the most recent runs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant