mirror of
https://github.com/lighttransport/tinyusdz.git
synced 2026-01-18 01:11:17 +01:00
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:
121
web/binding.cc
121
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<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)
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
87
web/js/test-coroutine-async.mjs
Normal file
87
web/js/test-coroutine-async.mjs
Normal 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);
|
||||
Reference in New Issue
Block a user