diff --git a/web/binding.cc b/web/binding.cc index 479b6672..d01de3d8 100644 --- a/web/binding.cc +++ b/web/binding.cc @@ -111,6 +111,51 @@ EM_JS(void, reportTydraComplete, (int meshCount, int materialCount, int textureC } }); +// ============================================================================ +// C++20 Coroutine Support: Yield to JavaScript event loop +// ============================================================================ +// This allows the browser to repaint between processing phases. +// Returns a Promise that resolves on the next animation frame. + +EM_JS(emscripten::EM_VAL, yieldToEventLoop_impl, (), { + // Return a Promise that resolves on next animation frame + // This gives the browser a chance to repaint + return Emval.toHandle(new Promise(resolve => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => resolve()); + } else { + // Fallback for non-browser environments (Node.js) + setTimeout(resolve, 0); + } + })); +}); + +// Wrapper for co_await usage +inline emscripten::val yieldToEventLoop() { + return emscripten::val::take_ownership(yieldToEventLoop_impl()); +} + +// Helper to yield with a custom delay (milliseconds) +EM_JS(emscripten::EM_VAL, yieldWithDelay_impl, (int delayMs), { + return Emval.toHandle(new Promise(resolve => { + setTimeout(resolve, delayMs); + })); +}); + +inline emscripten::val yieldWithDelay(int delayMs) { + return emscripten::val::take_ownership(yieldWithDelay_impl(delayMs)); +} + +// Report that async operation is starting (for JS progress UI) +EM_JS(void, reportAsyncPhaseStart, (const char* phase, float progress), { + if (typeof Module.onAsyncPhaseStart === 'function') { + Module.onAsyncPhaseStart({ + phase: UTF8ToString(phase), + progress: progress + }); + } +}); + namespace detail { std::array toArray(const tinyusdz::value::matrix3d &m) { @@ -1220,6 +1265,81 @@ class TinyUSDZLoaderNative { } + // ============================================================================ + // C++20 Coroutine-based Async Loading + // ============================================================================ + // This method uses C++20 coroutines to yield to the JavaScript event loop + // between processing phases, allowing the browser to repaint during loading. + // + // Returns a Promise that resolves to a JS object: { success: bool, error?: string } + // + emscripten::val loadFromBinaryAsync(std::string binary, std::string filename) { + // IMPORTANT: Parameters are passed by VALUE (not by reference) to ensure + // data remains valid across co_await suspension points. References would + // become dangling after the coroutine yields to the event loop. + + // Phase 1: Initial setup and format detection + reportAsyncPhaseStart("detecting", 0.0f); + + bool is_usdz = tinyusdz::IsUSDZ( + reinterpret_cast(binary.c_str()), binary.size()); + + // Yield to allow UI to show "detecting" phase + co_await yieldToEventLoop(); + + // Phase 2: Parsing USD + reportAsyncPhaseStart("parsing", 0.1f); + + tinyusdz::USDLoadOptions options; + options.max_memory_limit_in_mb = max_memory_limit_mb_; + + tinyusdz::Stage stage; + loaded_ = tinyusdz::LoadUSDFromMemory( + reinterpret_cast(binary.c_str()), binary.size(), + filename, &stage, &warn_, &error_, options); + + if (!loaded_) { + emscripten::val result = emscripten::val::object(); + result.set("success", false); + result.set("error", error_); + co_return result; + } + + loaded_as_layer_ = false; + filename_ = filename; + + // Yield after parsing to allow UI update + co_await yieldToEventLoop(); + + // Phase 3: Converting to RenderScene (Tydra) + reportAsyncPhaseStart("converting", 0.3f); + + // Yield again before heavy conversion + co_await yieldToEventLoop(); + + bool convert_ok = stageToRenderScene(stage, is_usdz, binary); + + if (!convert_ok) { + emscripten::val result = emscripten::val::object(); + result.set("success", false); + result.set("error", error_); + co_return result; + } + + // Phase 4: Complete + reportAsyncPhaseStart("complete", 1.0f); + + // Final yield to ensure UI updates + co_await yieldToEventLoop(); + + emscripten::val result = emscripten::val::object(); + result.set("success", true); + result.set("meshCount", static_cast(render_scene_.meshes.size())); + result.set("materialCount", static_cast(render_scene_.materials.size())); + result.set("textureCount", static_cast(render_scene_.textures.size())); + co_return result; + } + // u8 : Uint8Array object. bool loadTest(const std::string &filename, const emscripten::val &u8) { @@ -4373,6 +4493,7 @@ EMSCRIPTEN_BINDINGS(tinyusdz_module) { #endif .function("loadAsLayerFromBinary", &TinyUSDZLoaderNative::loadAsLayerFromBinary) .function("loadFromBinary", &TinyUSDZLoaderNative::loadFromBinary) + .function("loadFromBinaryAsync", &TinyUSDZLoaderNative::loadFromBinaryAsync) // C++20 coroutine async version .function("loadTest", &TinyUSDZLoaderNative::loadTest) .function("loadFromCachedAsset", &TinyUSDZLoaderNative::loadFromCachedAsset) .function("loadAsLayerFromCachedAsset", &TinyUSDZLoaderNative::loadAsLayerFromCachedAsset) diff --git a/web/js/src/tinyusdz/TinyUSDZLoader.js b/web/js/src/tinyusdz/TinyUSDZLoader.js index 1c11b4c7..0734346a 100644 --- a/web/js/src/tinyusdz/TinyUSDZLoader.js +++ b/web/js/src/tinyusdz/TinyUSDZLoader.js @@ -702,6 +702,120 @@ class TinyUSDZLoader extends Loader { }); } + /** + * Parse USD binary data using C++20 coroutine-based async loading. + * This method yields to the JavaScript event loop between processing phases, + * allowing the browser to repaint during loading. + * + * Unlike parseWithProgress which uses setTimeout for a single yield, + * this method uses true C++20 coroutines with co_await to yield multiple times + * during parsing (detecting -> parsing -> converting -> complete). + * + * @param {ArrayBuffer} binary - Binary USD data + * @param {string} filePath - Optional file path + * @param {Object} options - Parsing options + * @param {number} options.maxMemoryLimitMB - Override memory limit + * @param {Function} options.onPhaseStart - Callback when a phase starts ({phase, progress}) => void + * @returns {Promise} Parsed USD object + */ + async parseAsync(binary /* ArrayBuffer */, filePath /* optional */, options = {}) { + if (!this.native_) { + await this.init(); + } + + const usd = new this.native_.TinyUSDZLoaderNative(); + + // Set memory limit before loading if specified + const memoryLimit = options.maxMemoryLimitMB || this.maxMemoryLimitMB_; + if (memoryLimit !== undefined) { + usd.setMaxMemoryLimitMB(memoryLimit); + } + + // Set bone reduction configuration + if (this.enableBoneReduction_) { + usd.setEnableBoneReduction(true); + usd.setTargetBoneCount(this.targetBoneCount_ || 4); + } + + // Set up async phase callback on Module if provided + if (options.onPhaseStart) { + this.native_.onAsyncPhaseStart = options.onPhaseStart; + } + + try { + // Call the C++20 coroutine-based async loader + // This returns a Promise that resolves to { success, error?, meshCount?, materialCount?, textureCount? } + const result = await usd.loadFromBinaryAsync(binary, filePath || ''); + + if (!result.success) { + throw new Error(`TinyUSDZLoader: Failed to load USD: ${result.error || 'unknown error'}`); + } + + return usd; + } finally { + // Clean up callback + if (options.onPhaseStart) { + this.native_.onAsyncPhaseStart = null; + } + } + } + + /** + * Load USD file from URL using C++20 coroutine-based async loading. + * This method yields to the JavaScript event loop during processing, + * allowing the browser to repaint. + * + * @param {string} url - URL to load from + * @param {Object} options - Loading options + * @param {number} options.maxMemoryLimitMB - Override memory limit + * @param {Function} options.onPhaseStart - Callback when a phase starts ({phase, progress}) => void + * @param {Function} options.onFetchProgress - Fetch progress callback (loaded, total) => void + * @returns {Promise} Parsed USD object + */ + async loadAsync(url, options = {}) { + if (!this.native_) { + await this.init(); + } + + // Fetch the file with progress + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + // Get content length for progress calculation + const contentLength = response.headers.get('content-length'); + const total = contentLength ? parseInt(contentLength, 10) : 0; + + // Read response with progress + const reader = response.body.getReader(); + const chunks = []; + let loaded = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + loaded += value.length; + + if (options.onFetchProgress) { + options.onFetchProgress(loaded, total); + } + } + + // Combine chunks + const binary = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { + binary.set(chunk, offset); + offset += chunk.length; + } + + // Parse using coroutine-based async method + return this.parseAsync(binary, url, options); + } + /** * Load USD file from URL with progress reporting * diff --git a/web/js/test-coroutine-async.mjs b/web/js/test-coroutine-async.mjs new file mode 100644 index 00000000..4c46f272 --- /dev/null +++ b/web/js/test-coroutine-async.mjs @@ -0,0 +1,87 @@ +/** + * Test for C++20 coroutine-based async USD loading + * + * This tests the new loadFromBinaryAsync method that uses C++20 coroutines + * to yield to the JavaScript event loop between processing phases. + * + * Run with: node test-coroutine-async.mjs + */ + +import { TinyUSDZLoader } from './src/tinyusdz/TinyUSDZLoader.js'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +async function testCoroutineAsync() { + console.log('=== C++20 Coroutine Async Loading Test ===\n'); + + const loader = new TinyUSDZLoader(); + await loader.init(); + + // Test file - use a binary USDC or USDZ file for proper testing + // USDA files are text and need different handling + let testFile = join(__dirname, 'assets', 'suzanne.usdc'); + + let binary; + try { + // Try USDC first + binary = new Uint8Array(readFileSync(testFile)); + console.log(`Loaded test file: ${testFile} (${binary.length} bytes)\n`); + } catch (e) { + // Fallback to USDA (read as string, convert to Uint8Array) + try { + testFile = join(__dirname, 'assets', 'cube-xform.usda'); + const text = readFileSync(testFile, 'utf-8'); + const encoder = new TextEncoder(); + binary = encoder.encode(text); + console.log(`Loaded test file (text): ${testFile} (${binary.length} bytes)\n`); + } catch (e2) { + console.error(`Failed to read test file: ${testFile}`); + console.error('Please ensure the test file exists.'); + process.exit(1); + } + } + + // Track phases + const phases = []; + const startTime = performance.now(); + + console.log('Starting async parse with coroutine yields...\n'); + + try { + // Use the new coroutine-based async parser + const usd = await loader.parseAsync(binary, testFile, { + onPhaseStart: (info) => { + const elapsed = (performance.now() - startTime).toFixed(1); + phases.push({ ...info, elapsed }); + console.log(`[${elapsed}ms] Phase: ${info.phase} (${(info.progress * 100).toFixed(0)}%)`); + } + }); + + const totalTime = (performance.now() - startTime).toFixed(1); + + console.log('\n=== Results ==='); + console.log(`Total time: ${totalTime}ms`); + console.log(`Phases observed: ${phases.length}`); + console.log(`Meshes: ${usd.numMeshes()}`); + console.log(`Materials: ${usd.numMaterials()}`); + console.log(`Textures: ${usd.numTextures()}`); + + console.log('\n=== Phase Timeline ==='); + phases.forEach((p, i) => { + console.log(` ${i + 1}. ${p.phase} at ${p.elapsed}ms (${(p.progress * 100).toFixed(0)}%)`); + }); + + console.log('\n[PASS] Coroutine async loading completed successfully!'); + + } catch (error) { + console.error('\n[FAIL] Error during async loading:', error); + process.exit(1); + } +} + +// Run test +testCoroutineAsync().catch(console.error);