From e47809d50dc06e2aece83d6c2b4afa5a3327b164 Mon Sep 17 00:00:00 2001 From: Saran Tunyasuvunakool Date: Thu, 14 May 2026 03:39:04 -0700 Subject: [PATCH] Refactor MuJoCo Live to separate out HTML, CSS, and JS components. PiperOrigin-RevId: 915340338 Change-Id: Ic62b9d640af9967a59a77fa59a3556b916d5a24c --- .github/workflows/build.yml | 2 + .github/workflows/build_steps.sh | 2 +- .github/workflows/live.yml | 4 +- src/experimental/studio/CMakeLists.txt | 14 +- .../studio/{wasm.cc => emscripten.cc} | 0 src/experimental/studio/index.html | 246 ------------------ src/experimental/studio/live.css | 62 +++++ src/experimental/studio/live.html | 23 ++ src/experimental/studio/live.js | 212 +++++++++++++++ 9 files changed, 310 insertions(+), 255 deletions(-) rename src/experimental/studio/{wasm.cc => emscripten.cc} (100%) delete mode 100644 src/experimental/studio/index.html create mode 100644 src/experimental/studio/live.css create mode 100644 src/experimental/studio/live.html create mode 100644 src/experimental/studio/live.js diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a207fd9..7a82b374 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,8 @@ name: build on: push: + branches: + - main paths-ignore: - "doc/**" - "**/README.md" diff --git a/.github/workflows/build_steps.sh b/.github/workflows/build_steps.sh index 9e5440f9..80b2d21d 100755 --- a/.github/workflows/build_steps.sh +++ b/.github/workflows/build_steps.sh @@ -317,7 +317,7 @@ build_mujoco_live() { -DMUJOCO_USE_FILAMENT=ON \ -DMUJOCO_BUILD_TESTS_WASM=OFF \ -DMUJOCO_NATIVE_BUILD_DIR=$(pwd)/build_host - cmake --build build_wasm --target mujoco_live -j$(nproc) + cmake --build build_wasm --target mujoco_studio -j$(nproc) } diff --git a/.github/workflows/live.yml b/.github/workflows/live.yml index e5b6e152..ea07f79d 100644 --- a/.github/workflows/live.yml +++ b/.github/workflows/live.yml @@ -32,7 +32,9 @@ jobs: run: | mkdir -p dist/bin cp -r build_wasm/bin/* dist/bin/ - cp src/experimental/studio/index.html dist/index.html + cp src/experimental/studio/live.html dist/index.html + cp src/experimental/studio/live.css dist/live.css + cp src/experimental/studio/live.js dist/live.js COMMIT_HASH=$(git rev-parse HEAD 2>/dev/null || true) if [ -n "$COMMIT_HASH" ]; then sed -i "s/__COMMIT_HASH_PLACEHOLDER__/$COMMIT_HASH/g" dist/index.html diff --git a/src/experimental/studio/CMakeLists.txt b/src/experimental/studio/CMakeLists.txt index 11681425..9cdf3791 100644 --- a/src/experimental/studio/CMakeLists.txt +++ b/src/experimental/studio/CMakeLists.txt @@ -98,19 +98,19 @@ endif() # WASM Live target. if(EMSCRIPTEN) - add_executable(mujoco_live) - target_sources(mujoco_live PRIVATE wasm.cc) - target_compile_options(mujoco_live PRIVATE -g) - configure_studio_target(mujoco_live) - target_link_libraries(mujoco_live PRIVATE mujoco::render_noop) + add_executable(mujoco_studio) + target_sources(mujoco_studio PRIVATE emscripten.cc) + target_compile_options(mujoco_studio PRIVATE -g) + configure_studio_target(mujoco_studio) + target_link_libraries(mujoco_studio PRIVATE mujoco::render_noop) # Ensure resource decoder plugins are linked into the WASM binary. - target_link_options(mujoco_live PRIVATE + target_link_options(mujoco_studio PRIVATE -Wl,--whole-archive $ -Wl,--no-whole-archive ) # Filament's OpenGL backend requires WebGL2 / full ES3 bindings. - target_link_options(mujoco_live PRIVATE + target_link_options(mujoco_studio PRIVATE --bind -sUSE_WEBGL2=1 -sFULL_ES3 diff --git a/src/experimental/studio/wasm.cc b/src/experimental/studio/emscripten.cc similarity index 100% rename from src/experimental/studio/wasm.cc rename to src/experimental/studio/emscripten.cc diff --git a/src/experimental/studio/index.html b/src/experimental/studio/index.html deleted file mode 100644 index 0b667c75..00000000 --- a/src/experimental/studio/index.html +++ /dev/null @@ -1,246 +0,0 @@ - - - - - MuJoCo Live - - -
__COMMIT_HASH_PLACEHOLDER__
- - - - - - diff --git a/src/experimental/studio/live.css b/src/experimental/studio/live.css new file mode 100644 index 00000000..74d7cfe9 --- /dev/null +++ b/src/experimental/studio/live.css @@ -0,0 +1,62 @@ +#loadingOverlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.6); + z-index: 10000; + display: none; + align-items: center; + justify-content: center; +} +.loading-container { + color: #fff; + font-size: 24px; + font-family: sans-serif; + text-align: center; +} +.loading-title { + margin-bottom: 16px; +} +.loading-subtitle { + font-size: 14px; + opacity: 0.7; +} +#dropOverlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + z-index: 9999; + display: none; + pointer-events: none; + align-items: center; + justify-content: center; +} +.drop-message { + color: #fff; + font-size: 24px; + font-family: sans-serif; + padding: 32px 48px; + border: 3px dashed #fff; + border-radius: 16px; +} +#commitHash { + position: absolute; + top: 3px; + right: 3px; + z-index: 1; + font-family: monospace; + font-size: 7pt; + color: #fff; + mix-blend-mode: difference; + user-select: all; +} +#canvas { + width: 100vw; + height: 100vh; + display: block; +} diff --git a/src/experimental/studio/live.html b/src/experimental/studio/live.html new file mode 100644 index 00000000..9e101e02 --- /dev/null +++ b/src/experimental/studio/live.html @@ -0,0 +1,23 @@ + + + + + + MuJoCo Live + + +
+
+
Loading model…
+
This may take a moment for large models.
+
+
+
+
Drop model file here
+
+
__COMMIT_HASH_PLACEHOLDER__
+ + + + + diff --git a/src/experimental/studio/live.js b/src/experimental/studio/live.js new file mode 100644 index 00000000..19ecb32d --- /dev/null +++ b/src/experimental/studio/live.js @@ -0,0 +1,212 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// --- Loading overlay (shown while model is compiling) --- +const loadingOverlay = document.getElementById('loadingOverlay'); +function showLoading() { + loadingOverlay.style.display = 'flex'; +} +function hideLoading() { + loadingOverlay.style.display = 'none'; +} + +var Module = { + preRun: [], + postRun: [], + locateFile: function (path) { + const baseURL = window.location.origin + window.location.pathname.substring(0, window.location.pathname.lastIndexOf("/")); + return baseURL + "/bin/" + path; + }, + print: console.log, + printErr: text => { + console.error(text + "\n" + new Error().stack); + }, + canvas: (() => { + const canvas = document.getElementById("canvas"); + // As a default initial behavior, pop up an alert when webgl context is lost. To make your + // application robust, you may want to override this behavior before shipping! + // See http://www.khronos.org/registry/webgl/specs/latest/1.0/#5.15.2 + canvas.addEventListener( + "webglcontextlost", + e => { + alert("WebGL context lost. You will need to reload the page."); + e.preventDefault(); + }, + false, + ); + return canvas; + })(), + setStatus(text) { }, + totalDependencies: 0, + monitorRunDependencies(left) { }, + onRuntimeInitialized: () => { + // Define assets to prefetch, relative to the WASM directory. + const assetsToPrefetch = [ + "assets/fontawesome-webfont.ttf", + "assets/ibl.ktx", + "assets/OpenSans-Regular.ttf", + "assets/pbr.filamat", + "assets/pbr_packed.filamat", + "assets/phong_2d_fade.filamat", + "assets/phong_2d.filamat", + "assets/phong_2d_reflect.filamat", + "assets/phong_2d_uv_fade.filamat", + "assets/phong_2d_uv.filamat", + "assets/phong_2d_uv_reflect.filamat", + "assets/phong_color_fade.filamat", + "assets/phong_color.filamat", + "assets/phong_color_reflect.filamat", + "assets/phong_cube_fade.filamat", + "assets/phong_cube.filamat", + "assets/phong_cube_reflect.filamat", + "assets/unlit_decor.filamat", + "assets/unlit_depth.filamat", + "assets/unlit_segmentation.filamat", + "assets/unlit_ui.filamat" + ]; + + const assetPromises = assetsToPrefetch.map(async (relativePath) => { + const assetUrl = Module.locateFile(relativePath); + try { + const response = await fetch(assetUrl); + if (!response.ok) { + throw new Error(`Failed to fetch ${assetUrl}: ${response.statusText}`); + } + const buffer = await response.arrayBuffer(); + const filename = relativePath.substring(relativePath.lastIndexOf('/') + 1); + Module.registerAsset(filename, new Uint8Array(buffer)); + console.log(`Registered asset: ${filename}`); + } catch (error) { + console.error(`Error prefetching asset ${assetUrl}:`, error); + throw error; // Re-throw to be caught by Promise.all + } + }); + + Promise.all(assetPromises) + .then(() => { + try { + Module.init(); + + // Check for a ?model= URL parameter and load from URL. + const params = new URLSearchParams(window.location.search); + const modelUrl = params.get('model'); + if (modelUrl) { + // loadUrl uses ASYNCIFY (via EM_ASYNC_JS fetch), which + // suspends the WASM module. We must not start the animation + // loop until the load completes, otherwise renderFrame will + // hit "Cannot have multiple async operations in flight". + showLoading(); + requestAnimationFrame(() => { + requestAnimationFrame(async () => { + try { + await Module.loadUrl(modelUrl); + } catch (error) { + console.error('Failed to load model from URL:', error); + } finally { + hideLoading(); + requestAnimationFrame(Module.animate); + } + }); + }); + } else { + requestAnimationFrame(Module.animate); + } + } catch (error) { + console.error('Failed to initialize app.', error); + } + }) + .catch((error) => { + console.error('Failed to prefetch one or more assets.', error); + }); + }, + animate: async () => { + try { + await Module.renderFrame(); + } catch (error) { + console.error('Update error:', error); + } + requestAnimationFrame(Module.animate); + }, +}; +// Ensure the canvas is resized when the window is resized. +window.addEventListener("resize", function () { + Module.canvas.style.width = window.innerWidth + "px"; + Module.canvas.style.height = window.innerHeight + "px"; +}); + +function handleFile(file) { + const reader = new FileReader(); + reader.onload = (e) => { + const buffer = e.target.result; + // Show the loading overlay, then defer the blocking WASM call by two + // animation frames so the browser has a chance to paint the overlay. + showLoading(); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + Module.loadFile(file.name, buffer); + } catch (error) { + console.error('Failed to load model from file:', error); + } finally { + hideLoading(); + } + }); + }); + }; + reader.onerror = (e) => { + console.error('Error reading file:', e); + }; + reader.readAsArrayBuffer(file); +} + +document.addEventListener('DOMContentLoaded', () => { + // Remove commit hash if not substituted + var el = document.getElementById('commitHash'); + if (el && el.textContent === '__COMMIT_HASH_PLACEHOLDER__') el.remove(); + + // --- Drag-and-drop support --- + const dropOverlay = document.getElementById('dropOverlay'); + + let dragCounter = 0; + + document.addEventListener('dragenter', (e) => { + e.preventDefault(); + dragCounter++; + if (dragCounter === 1) { + dropOverlay.style.display = 'flex'; + } + }); + + document.addEventListener('dragleave', (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter === 0) { + dropOverlay.style.display = 'none'; + } + }); + + document.addEventListener('dragover', (e) => { + e.preventDefault(); + }); + + document.addEventListener('drop', (e) => { + e.preventDefault(); + dragCounter = 0; + dropOverlay.style.display = 'none'; + const files = e.dataTransfer.files; + for (let i = 0; i < files.length; i++) { + handleFile(files[i]); + } + }); +});