Add C++20 coroutine-based async USD loading with browser yield support

- Add yieldToEventLoop() helper using EM_JS that returns a Promise for requestAnimationFrame
- Implement loadFromBinaryAsync() coroutine that yields between processing phases:
  - detecting: format detection
  - parsing: USD parsing
  - converting: Tydra conversion
  - complete: done
- Add reportAsyncPhaseStart() EM_JS callback for JS progress tracking
- Add parseAsync() and loadAsync() methods to TinyUSDZLoader.js
- Parameters passed by value (not reference) to survive co_await suspension points
- No ASYNCIFY required - uses native LLVM coroutine transform

This allows the browser to repaint between processing phases, improving
perceived loading performance for large USD files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Syoyo Fujita
2026-01-07 00:22:07 +09:00
parent 3b4bf5a687
commit 9d58ccc7ed
3 changed files with 322 additions and 0 deletions

View File

@@ -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<double, 9> 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<const uint8_t *>(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<const uint8_t *>(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<int>(render_scene_.meshes.size()));
result.set("materialCount", static_cast<int>(render_scene_.materials.size()));
result.set("textureCount", static_cast<int>(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)

View File

@@ -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<Object>} 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<Object>} 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
*

View File

@@ -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);