Add EM_JS synchronous progress callbacks for Tydra conversion

- Add EM_JS functions in binding.cc for direct C++ to JS progress reporting
  - reportTydraProgress: mesh conversion progress (current/total, stage, name)
  - reportTydraStage: conversion stage changes
  - reportTydraComplete: conversion completion with counts
- Update TinyUSDZLoader.js with callback options:
  - onTydraProgress, onTydraStage, onTydraComplete
  - setTydraProgressCallback/setTydraStageCallback/setTydraCompleteCallback methods
- Integrate callbacks in progress-demo.js for real-time UI updates
- Add design document for JS/WASM synchronous event update patterns

This enables real-time progress updates during Tydra scene conversion
without requiring ASYNCIFY, using Emscripten's EM_JS for synchronous
JavaScript calls from C++.

🤖 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-04 22:41:47 +09:00
parent bb39395f3f
commit d04efdf8d3
4 changed files with 431 additions and 3 deletions

View File

@@ -70,6 +70,47 @@
using namespace emscripten;
// ============================================================================
// EM_JS: Synchronous JavaScript callbacks for progress reporting
// These functions are called from C++ during Tydra conversion to report
// progress to JavaScript in real-time without ASYNCIFY.
// ============================================================================
// Report mesh conversion progress
// Called for each mesh during Tydra conversion
EM_JS(void, reportTydraProgress, (int current, int total, const char* stage, const char* meshName, float progress), {
if (typeof Module.onTydraProgress === 'function') {
Module.onTydraProgress({
meshCurrent: current,
meshTotal: total,
stage: UTF8ToString(stage),
meshName: UTF8ToString(meshName),
progress: progress
});
}
});
// Report conversion stage change
EM_JS(void, reportTydraStage, (const char* stage, const char* message), {
if (typeof Module.onTydraStage === 'function') {
Module.onTydraStage({
stage: UTF8ToString(stage),
message: UTF8ToString(message)
});
}
});
// Report conversion completion
EM_JS(void, reportTydraComplete, (int meshCount, int materialCount, int textureCount), {
if (typeof Module.onTydraComplete === 'function') {
Module.onTydraComplete({
meshCount: meshCount,
materialCount: materialCount,
textureCount: textureCount
});
}
});
namespace detail {
std::array<double, 9> toArray(const tinyusdz::value::matrix3d &m) {
@@ -972,7 +1013,7 @@ class TinyUSDZLoaderNative {
// RenderScene: Scene graph object which is suited for GL/Vulkan renderer
tinyusdz::tydra::RenderSceneConverter converter;
// Set up detailed progress callback to update parsing_progress_
// Set up detailed progress callback to update parsing_progress_ and call JS
converter.SetDetailedProgressCallback(
[](const tinyusdz::tydra::DetailedProgressInfo &info, void *userptr) -> bool {
ParsingProgress *pp = static_cast<ParsingProgress *>(userptr);
@@ -987,6 +1028,16 @@ class TinyUSDZLoaderNative {
// Update progress: parsing is 0-80%, conversion is 80-100%
pp->progress = 0.8f + (info.progress * 0.2f);
}
// Call JavaScript synchronously via EM_JS
reportTydraProgress(
static_cast<int>(info.meshes_processed),
static_cast<int>(info.meshes_total),
info.GetStageName(), // Already returns const char*
info.current_mesh_name.c_str(),
info.progress
);
return true; // Continue conversion
},
&parsing_progress_);
@@ -1114,7 +1165,7 @@ class TinyUSDZLoaderNative {
// RenderScene: Scene graph object which is suited for GL/Vulkan renderer
tinyusdz::tydra::RenderSceneConverter converter;
// Set up detailed progress callback to update parsing_progress_
// Set up detailed progress callback to update parsing_progress_ and call JS
converter.SetDetailedProgressCallback(
[](const tinyusdz::tydra::DetailedProgressInfo &info, void *userptr) -> bool {
ParsingProgress *pp = static_cast<ParsingProgress *>(userptr);
@@ -1129,6 +1180,16 @@ class TinyUSDZLoaderNative {
// Update progress: parsing is 0-80%, conversion is 80-100%
pp->progress = 0.8f + (info.progress * 0.2f);
}
// Call JavaScript synchronously via EM_JS
reportTydraProgress(
static_cast<int>(info.meshes_processed),
static_cast<int>(info.meshes_total),
info.GetStageName(), // Already returns const char*
info.current_mesh_name.c_str(),
info.progress
);
return true; // Continue conversion
},
&parsing_progress_);

View File

@@ -0,0 +1,285 @@
# JS/WASM Synchronous Progress Update Design
## Overview
This document describes design options for synchronous progress reporting from C++/WASM to JavaScript without using ASYNCIFY. The goal is to enable interactive progress updates during Tydra scene conversion.
## Problem Statement
The current implementation uses `printf()` statements in C++ (render-data.cc) which output to the browser console synchronously. However, there's no mechanism to:
1. Intercept these messages in JavaScript
2. Parse progress information
3. Update UI elements in real-time
## Key Insight: Emscripten printf() Behavior
Based on research ([Emscripten Module documentation](https://emscripten.org/docs/api_reference/module.html)):
- `printf()` in WASM outputs to console.log (line-buffered, needs `\n`)
- Output goes through `Module['print']` (stdout) or `Module['printErr']` (stderr)
- `Module['print']` can be overridden **before** module initialization
- Emscripten provides low-level functions: `emscripten_out()`, `emscripten_err()`, `emscripten_console_log()`
## Design Options
### Option 1: Module.print Override (Recommended)
**Approach**: Override `Module['print']` to intercept and parse progress messages from C++.
**Pros**:
- No C++ changes required (already using printf)
- Works with existing progress logging
- Zero overhead when not monitoring
- Clean separation of concerns
**Cons**:
- Requires parsing string output
- Must define Module before WASM loads
**Implementation**:
```javascript
// In pre-module-init code (before WASM loads)
const progressParser = {
onProgress: null,
totalMeshes: 0,
currentMesh: 0
};
// Pattern: [Tydra] Mesh 3/10: /root/mesh_003
const MESH_PROGRESS_REGEX = /\[Tydra\] Mesh (\d+)\/(\d+)(?:\s*\([^)]+\))?:\s*(.+)/;
const COUNT_REGEX = /\[Tydra\] Found (\d+) meshes/;
const COMPLETE_REGEX = /\[Tydra\] Conversion complete/;
function createModuleWithProgressCapture(onProgress) {
progressParser.onProgress = onProgress;
return {
print: function(text) {
console.log(text); // Keep console output
// Parse progress messages
let match;
if ((match = MESH_PROGRESS_REGEX.exec(text))) {
const current = parseInt(match[1]);
const total = parseInt(match[2]);
const meshName = match[3];
onProgress({
type: 'mesh',
current,
total,
name: meshName,
percentage: (current / total) * 100
});
} else if ((match = COUNT_REGEX.exec(text))) {
progressParser.totalMeshes = parseInt(match[1]);
onProgress({
type: 'count',
total: progressParser.totalMeshes
});
} else if (COMPLETE_REGEX.test(text)) {
onProgress({
type: 'complete'
});
}
},
printErr: function(text) {
console.error(text);
}
};
}
// Usage in TinyUSDZLoader.js
const Module = createModuleWithProgressCapture((progress) => {
updateProgressBar(progress.percentage);
updateMeshStatus(`${progress.current}/${progress.total}: ${progress.name}`);
});
```
**Integration with existing code**:
```javascript
// progress-demo.js modification
async function loadUSDWithProgress(url) {
// Create loader with progress capture
loaderState.loader = new TinyUSDZLoader({
moduleOverrides: {
print: (text) => {
console.log(text);
parseTydraProgress(text);
}
}
});
// ... rest of loading code
}
```
---
### Option 2: EM_ASM Direct JavaScript Call
**Approach**: Call JavaScript directly from C++ using `EM_ASM` macro.
**Pros**:
- Direct, typed communication
- No string parsing required
- Can call any JavaScript function
**Cons**:
- Requires C++ code changes
- Adds emscripten-specific code to core library
- Compile-time dependency on emscripten headers
**Implementation**:
```cpp
// In render-data.cc (or a wrapper layer)
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
void ReportMeshProgressToJS(size_t current, size_t total, const char* meshName) {
EM_ASM({
if (typeof Module.onMeshProgress === 'function') {
Module.onMeshProgress($0, $1, UTF8ToString($2));
}
}, current, total, meshName);
}
#else
void ReportMeshProgressToJS(size_t, size_t, const char*) {}
#endif
// Call from MeshVisitor:
ReportMeshProgressToJS(meshes_processed, meshes_total, path.c_str());
```
```javascript
// JavaScript side
const Module = {
onMeshProgress: function(current, total, name) {
updateProgressUI(current, total, name);
}
};
```
---
### Option 3: EM_JS Function Declaration
**Approach**: Declare JavaScript function inline in C++ header.
**Pros**:
- Cleaner than EM_ASM
- Type-safe function signature
- Compiled once
**Cons**:
- Same as Option 2 (requires C++ changes)
**Implementation**:
```cpp
// binding.cc or progress-bridge.h
#include <emscripten/em_js.h>
EM_JS(void, reportMeshProgress, (int current, int total, const char* name), {
if (Module.onMeshProgress) {
Module.onMeshProgress(current, total, UTF8ToString(name));
}
});
// Use in render-data.cc via extern declaration or header include
extern "C" void reportMeshProgress(int current, int total, const char* name);
```
---
### Option 4: Shared Memory Buffer
**Approach**: Use a shared memory region that C++ writes to and JS polls.
**Pros**:
- No function call overhead
- Works with SharedArrayBuffer for workers
- Can be polled at any rate
**Cons**:
- Requires polling from JS side
- More complex synchronization
- SharedArrayBuffer requires specific headers
**Implementation**:
```cpp
// C++ side
struct ProgressBuffer {
uint32_t currentMesh;
uint32_t totalMeshes;
uint32_t flags; // bit 0: updated, bit 1: complete
char meshName[256];
};
// Exposed via embind
ProgressBuffer* getProgressBuffer();
```
```javascript
// JS side - poll during animation frame
function pollProgress() {
const buffer = Module.getProgressBuffer();
if (buffer.flags & 1) { // updated flag
updateUI(buffer.currentMesh, buffer.totalMeshes, buffer.meshName);
buffer.flags &= ~1; // clear flag
}
if (!(buffer.flags & 2)) { // not complete
requestAnimationFrame(pollProgress);
}
}
```
---
## Recommendation
**Option 1 (Module.print Override)** is recommended because:
1. **Zero C++ changes**: Current printf statements already work
2. **Non-invasive**: Doesn't pollute core library with emscripten code
3. **Flexible**: Can enable/disable progress capture per-load
4. **Maintainable**: All JS-specific code stays in JS layer
5. **Proven pattern**: Used by many emscripten projects
## Implementation Plan
### Phase 1: Basic Module.print Override
1. Modify TinyUSDZLoader.js to accept `moduleOverrides` option
2. Create progress message parser utility
3. Connect parser to progress UI in progress-demo.js
### Phase 2: Structured Progress Messages (Optional Enhancement)
1. Standardize printf format in render-data.cc with JSON-parseable structure
2. Example: `[TYDRA:PROGRESS] {"type":"mesh","current":3,"total":10,"name":"/root/mesh"}`
3. Makes parsing more robust
### Phase 3: Progress Event System
1. Create EventEmitter-style interface for progress events
2. Support multiple listeners
3. Add batch/debounce option for high-frequency updates
## Current printf Format Reference
From render-data.cc:
```
[Tydra] Counting primitives...
[Tydra] Found 10 meshes (8 mesh, 1 cube, 1 sphere), 3 materials
[Tydra] Mesh 1/10: /root/Mesh_001
[Tydra] Mesh 2/10: /root/Mesh_002
[Tydra] Mesh 3/10 (cube): /root/Cube_001
[Tydra] Mesh 4/10 (sphere): /root/Sphere_001
[Tydra] Conversion complete: 10 meshes, 3 materials, 5 textures
```
## References
- [Emscripten Module object documentation](https://emscripten.org/docs/api_reference/module.html)
- [Emscripten console.h documentation](https://emscripten.org/docs/api_reference/console.h.html)
- [GitHub Discussion: How does emscripten_console_xxx work?](https://github.com/emscripten-core/emscripten/discussions/21783)

View File

@@ -879,7 +879,33 @@ async function loadUSDWithProgress(source, isFile = false) {
try {
// Initialize loader if needed
if (!loaderState.loader) {
loaderState.loader = new TinyUSDZLoader();
loaderState.loader = new TinyUSDZLoader(undefined, {
// EM_JS synchronous progress callback - called directly from C++ during conversion
onTydraProgress: (info) => {
// info: {meshCurrent, meshTotal, stage, meshName, progress}
const meshProgress = info.meshTotal > 0
? `${info.meshCurrent}/${info.meshTotal}`
: '';
const meshName = info.meshName ? info.meshName.split('/').pop() : '';
updateProgressUI({
stage: 'parsing',
percentage: 30 + (info.progress * 50), // parsing takes 30-80%
message: meshProgress
? `Converting: ${meshProgress} ${meshName}`
: `Converting: ${info.stage}`
});
},
onTydraComplete: (info) => {
// info: {meshCount, materialCount, textureCount}
console.log(`[Tydra] Complete: ${info.meshCount} meshes, ${info.materialCount} materials, ${info.textureCount} textures`);
updateProgressUI({
stage: 'building',
percentage: 80,
message: `Building ${info.meshCount} meshes...`
});
}
});
await loaderState.loader.init();
// Setup TinyUSDZ for MaterialX texture decoding

View File

@@ -134,6 +134,9 @@ class TinyUSDZLoader extends Loader {
* @param {Object} options - Configuration options
* @param {number} options.maxMemoryLimitMB - Maximum memory limit in MB (default: 2048 for WASM32, 8192 for WASM64)
* @param {boolean} options.useZstdCompressedWasm - Use compressed WASM (default: false)
* @param {Function} options.onTydraProgress - Callback for Tydra conversion progress ({meshCurrent, meshTotal, stage, meshName, progress}) => void
* @param {Function} options.onTydraStage - Callback for Tydra stage changes ({stage, message}) => void
* @param {Function} options.onTydraComplete - Callback for Tydra conversion completion ({meshCount, materialCount, textureCount}) => void
*/
constructor(manager, options = {}) {
super(manager);
@@ -163,6 +166,12 @@ class TinyUSDZLoader extends Loader {
// Memory limit in MB - defaults are set by the native module based on WASM architecture
// (2GB for WASM32, 8GB for WASM64). If not specified, the native default will be used.
this.maxMemoryLimitMB_ = options.maxMemoryLimitMB;
// EM_JS synchronous progress callbacks for Tydra conversion
// These are called directly from C++ during conversion without ASYNCIFY
this.onTydraProgress_ = options.onTydraProgress || null;
this.onTydraStage_ = options.onTydraStage || null;
this.onTydraComplete_ = options.onTydraComplete || null;
}
// Decompress zstd compressed WASM
@@ -274,6 +283,18 @@ class TinyUSDZLoader extends Loader {
// return scriptDirectory + path;
//}
// Set up EM_JS synchronous progress callbacks
// These are called from C++ during Tydra conversion without ASYNCIFY
if (this.onTydraProgress_) {
initOptions.onTydraProgress = this.onTydraProgress_;
}
if (this.onTydraStage_) {
initOptions.onTydraStage = this.onTydraStage_;
}
if (this.onTydraComplete_) {
initOptions.onTydraComplete = this.onTydraComplete_;
}
this.native_ = await initTinyUSDZNative(initOptions);
if (!this.native_) {
throw new Error('TinyUSDZLoader: Failed to initialize native module.');
@@ -311,6 +332,41 @@ class TinyUSDZLoader extends Loader {
this.progressCallback_ = null;
}
/**
* Set Tydra progress callback for mesh conversion updates
* This is called synchronously from C++ via EM_JS during scene conversion
* @param {Function} callback - ({meshCurrent, meshTotal, stage, meshName, progress}) => void
*/
setTydraProgressCallback(callback) {
this.onTydraProgress_ = callback;
// Update native module if already initialized
if (this.native_) {
this.native_.onTydraProgress = callback;
}
}
/**
* Set Tydra stage callback for conversion stage changes
* @param {Function} callback - ({stage, message}) => void
*/
setTydraStageCallback(callback) {
this.onTydraStage_ = callback;
if (this.native_) {
this.native_.onTydraStage = callback;
}
}
/**
* Set Tydra completion callback
* @param {Function} callback - ({meshCount, materialCount, textureCount}) => void
*/
setTydraCompleteCallback(callback) {
this.onTydraComplete_ = callback;
if (this.native_) {
this.native_.onTydraComplete = callback;
}
}
/**
* Get current maximum memory limit in MB
* @returns {number|undefined} Memory limit in megabytes, or undefined if using native default