From fca913b4f544f4a1ebe2b722b4158b8975bccd17 Mon Sep 17 00:00:00 2001 From: Matija Kecman Date: Wed, 12 Aug 2026 04:38:02 -0700 Subject: [PATCH] Optimize and robustify asset prefetching and material loading. - Parallelize asset prefetching (max 4 workers) with retry logic and on-screen error banner, replacing silent failures and cryptic WASM out-of-bounds crashes with explicit missing-file errors. - Support binary Uint8Array/ArrayBuffer in C++ asset registry via typed_memory_view, preventing Embind string coercion from corrupting binary shader packages into ASCII text. - Automatically glob .mat files in CMake instead of maintaining a manual list. - Add missing reflection materials to prefetch arrays. - Disable caching for index.html in web server so template/script updates apply on normal page reload without stale cache issues, the file is small so caching had a negligible upside. - Add null resource and payload validation in ObjectManager::LoadMaterial before constructing Filament materials. PiperOrigin-RevId: 963364509 Change-Id: I7de79df6a5cede4e54683415ee193f1c5a665286 --- .../mujoco/experimental/studio/web/index.html | 67 +++++++++++++------ .../experimental/studio/web/web_client.cc | 27 +++++++- .../experimental/studio/web/web_server.py | 6 +- src/experimental/studio/live.js | 3 +- src/render/filament/CMakeLists.txt | 35 ++-------- src/render/filament/core/object_manager.cc | 11 +++ 6 files changed, 95 insertions(+), 54 deletions(-) diff --git a/python/mujoco/experimental/studio/web/index.html b/python/mujoco/experimental/studio/web/index.html index 1f12606c..26b1ca31 100644 --- a/python/mujoco/experimental/studio/web/index.html +++ b/python/mujoco/experimental/studio/web/index.html @@ -214,13 +214,15 @@ limitations under the License. } return prefix + path; }, - onRuntimeInitialized: () => { + onRuntimeInitialized: async () => { const assetsToPrefetch = [ "AtkinsonHyperlegibleNext[wght].ttf", "AtkinsonHyperlegibleMono-Regular.ttf", "fontawesome-webfont.ttf", "ibl.ktx", "pbr.filamat", + "pbr_reflect.filamat", + "pbr_packed_reflect.filamat", "pbr_transparent.filamat", "pbr_packed.filamat", "pbr_packed_transparent.filamat", @@ -245,28 +247,55 @@ limitations under the License. "unlit_ui.filamat", ]; - const assetPromises = assetsToPrefetch.map(async (filename) => { - for (let attempt = 0; attempt < 3; attempt++) { - try { - if (attempt > 0) { - await new Promise((r) => setTimeout(r, 500 * attempt)); + const PARALLEL = 4; + const failedAssets = []; + let nextIndex = 0; + + async function prefetchWorker() { + while (nextIndex < assetsToPrefetch.length) { + const filename = assetsToPrefetch[nextIndex++]; + let loaded = false; + for (let attempt = 0; attempt < 3; attempt++) { + try { + if (attempt > 0) { + await new Promise((r) => setTimeout(r, 500 * attempt)); + } + const response = await fetch("assets/" + filename); + if (!response.ok) continue; + const buffer = await response.arrayBuffer(); + Module.registerAsset(filename, new Uint8Array(buffer)); + loaded = true; + break; + } catch (error) { + console.warn(`Attempt ${attempt + 1} failed for ${filename}:`, error); } - const response = await fetch("assets/" + filename); - if (!response.ok) continue; - const buffer = await response.arrayBuffer(); - Module.registerAsset(filename, new Uint8Array(buffer)); - return; - } catch (error) { - console.warn(`Attempt ${attempt + 1} failed for ${filename}:`, error); + } + if (!loaded) { + console.error(`Error prefetching asset ${filename} after 3 attempts`); + failedAssets.push(filename); } } - console.error(`Error prefetching asset ${filename} after 3 attempts`); - }); + } - Promise.all(assetPromises).then(() => { - Module.startApp(); - reloadModel(); - }); + const workers = []; + for (let i = 0; i < Math.min(PARALLEL, assetsToPrefetch.length); i++) { + workers.push(prefetchWorker()); + } + await Promise.all(workers); + + if (failedAssets.length > 0) { + const msg = `Failed to load required assets (${failedAssets.join(", ")}). Please check server runfiles or connection and reload.`; + console.error(msg); + const errDiv = document.createElement("div"); + errDiv.style.cssText = + "position:absolute;top:20px;left:20px;right:20px;padding:16px;background:#7a1a1a;color:#fff;font-family:sans-serif;font-size:14px;border-radius:8px;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,0.5);"; + errDiv.textContent = msg; + document.body.appendChild(errDiv); + return; + } + + Module.startApp(); + reloadModel(); }, }; // Set only the CSS size; SDL owns canvas.width/height. Use whole diff --git a/python/mujoco/experimental/studio/web/web_client.cc b/python/mujoco/experimental/studio/web/web_client.cc index eee6ce29..53d5f945 100644 --- a/python/mujoco/experimental/studio/web/web_client.cc +++ b/python/mujoco/experimental/studio/web/web_client.cc @@ -715,9 +715,30 @@ class AssetRegistry { }; // Exposed to JS (see EMSCRIPTEN_BINDINGS): the page calls this once per asset. -void RegisterAsset(std::string filename, std::string contents) { - AssetRegistry::Instance().RegisterAsset(std::move(filename), - std::move(contents)); +void RegisterAsset(std::string filename, emscripten::val contents) { + std::string data; + if (contents.typeOf().as() == "string") { + data = contents.as(); + } else if (contents.instanceof(emscripten::val::global("Uint8Array")) || + contents.hasOwnProperty("length")) { + size_t len = contents["length"].as(); + data.resize(len); + if (len > 0) { + emscripten::val memory_view = + emscripten::val(emscripten::typed_memory_view(len, data.data())); + memory_view.call("set", contents); + } + } else if (contents.instanceof(emscripten::val::global("ArrayBuffer"))) { + emscripten::val u8 = emscripten::val::global("Uint8Array").new_(contents); + size_t len = u8["length"].as(); + data.resize(len); + if (len > 0) { + emscripten::val memory_view = + emscripten::val(emscripten::typed_memory_view(len, data.data())); + memory_view.call("set", u8); + } + } + AssetRegistry::Instance().RegisterAsset(std::move(filename), std::move(data)); } // Registers resource providers so that "filament:" and "font:" asset requests diff --git a/python/mujoco/experimental/studio/web/web_server.py b/python/mujoco/experimental/studio/web/web_server.py index b4dbab53..80eb532c 100644 --- a/python/mujoco/experimental/studio/web/web_server.py +++ b/python/mujoco/experimental/studio/web/web_server.py @@ -479,8 +479,12 @@ def _run_server( content_type = _CONTENT_TYPES.get( os.path.splitext(full)[1], "application/octet-stream" ) + cacheable = not rel.endswith("index.html") return Response( - 200, "OK", _http_headers(content_type, len(body), cacheable=True), body + 200, + "OK", + _http_headers(content_type, len(body), cacheable=cacheable), + body, ) async def main_loop() -> None: diff --git a/src/experimental/studio/live.js b/src/experimental/studio/live.js index 4d8bfec1..1e219b55 100644 --- a/src/experimental/studio/live.js +++ b/src/experimental/studio/live.js @@ -138,13 +138,14 @@ var Module = { totalDependencies: 0, monitorRunDependencies(left) { }, onRuntimeInitialized: () => { - // Define assets to prefetch, relative to the WASM directory. const assetsToPrefetch = [ "assets/fontawesome-webfont.ttf", "assets/ibl.ktx", "assets/AtkinsonHyperlegibleNext[wght].ttf", "assets/AtkinsonHyperlegibleMono-Regular.ttf", "assets/pbr.filamat", + "assets/pbr_reflect.filamat", + "assets/pbr_packed_reflect.filamat", "assets/pbr_transparent.filamat", "assets/pbr_packed.filamat", "assets/pbr_packed_transparent.filamat", diff --git a/src/render/filament/CMakeLists.txt b/src/render/filament/CMakeLists.txt index f6945da6..d05c7297 100644 --- a/src/render/filament/CMakeLists.txt +++ b/src/render/filament/CMakeLists.txt @@ -92,43 +92,18 @@ add_library(mujoco::filament ALIAS ${MUJOCO_FILAMENT_TARGET_NAME}) set(ASSETS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/assets") set(OUTPUT_ASSETS_DIR "${CMAKE_CURRENT_BINARY_DIR}/assets") file(MAKE_DIRECTORY ${OUTPUT_ASSETS_DIR}) + file(COPY "${ASSETS_DIR}/ibl.ktx" DESTINATION ${OUTPUT_ASSETS_DIR} ) set(MATC_EXECUTABLE matc) -set(MATERIAL_FILES - pbr.mat - pbr_transparent.mat - pbr_packed.mat - pbr_packed_transparent.mat - pbr_reflect.mat - pbr_packed_reflect.mat - phong_2d_fade.mat - phong_2d.mat - phong_2d_reflect.mat - phong_2d_uv_fade.mat - phong_2d_uv.mat - phong_2d_uv_reflect.mat - phong_color_fade.mat - phong_color.mat - phong_color_reflect.mat - phong_cube_fade.mat - phong_cube.mat - phong_cube_reflect.mat - outline_composite.mat - outline_flatten.mat - outline_jumpflood.mat - decor.mat - unlit_depth.mat - unlit_segmentation.mat - unlit_ui.mat -) +file(GLOB MATERIAL_FILES CONFIGURE_DEPENDS "${ASSETS_DIR}/*.mat") -foreach(MATERIAL_FILE ${MATERIAL_FILES}) - get_filename_component(MATERIAL_NAME ${MATERIAL_FILE} NAME_WE) - set(INPUT_FILE "${ASSETS_DIR}/${MATERIAL_FILE}") +foreach(INPUT_FILE ${MATERIAL_FILES}) + get_filename_component(MATERIAL_NAME ${INPUT_FILE} NAME_WE) + get_filename_component(MATERIAL_FILE ${INPUT_FILE} NAME) set(OUTPUT_FILE "${OUTPUT_ASSETS_DIR}/${MATERIAL_NAME}.filamat") if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") diff --git a/src/render/filament/core/object_manager.cc b/src/render/filament/core/object_manager.cc index 44565f0a..d6f4990b 100644 --- a/src/render/filament/core/object_manager.cc +++ b/src/render/filament/core/object_manager.cc @@ -42,8 +42,19 @@ static filament::Material* LoadMaterial(filament::Engine* engine, const std::string path = ResolveFilamentAssetPath(std::string(filename)); mjResource* resource = mju_openResource("", path.c_str(), nullptr, nullptr, 0); + if (!resource) { + mju_error("Failed to open filament asset '%.*s' at '%s'", + static_cast(filename.size()), filename.data(), path.c_str()); + return nullptr; + } void* payload = nullptr; int size = mju_readResource(resource, const_cast(&payload)); + if (size <= 0 || !payload) { + mju_error("Failed to read filament asset '%.*s' (size=%d)", + static_cast(filename.size()), filename.data(), size); + mju_closeResource(resource); + return nullptr; + } filament::Material::Builder material_builder; material_builder.package(payload, size); filament::Material* material = material_builder.build(*engine);