Add delayed texture loading with progress bar for faster initial scene render

- Add TextureLoadingManager class for queuing and progressive texture loading
- Add texture progress bar UI (bottom overlay with green theme)
- Modify buildSceneWithProgress to use delayed texture mode
- Scene renders immediately without textures, then textures load in background
- Support textureLoadingManager option in material conversion functions
- Update TinyUSDZMaterialX.js to support delayed loading for OpenPBR materials
- Textures load with concurrency control (2 at a time) and browser yields

🤖 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-06 07:46:03 +09:00
parent 4366fdd2f1
commit abd5d28771
4 changed files with 758 additions and 159 deletions

View File

@@ -183,10 +183,42 @@
#progress-bar {
height: 100%;
width: 0%;
width: 100%;
background: linear-gradient(90deg, #2196F3, #4CAF50);
border-radius: 8px;
transition: width 0.3s ease-out;
transform-origin: left center;
transform: scaleX(0);
/* Use will-change to hint compositor for independent animation */
will-change: transform;
position: relative;
}
/* Animated stripe overlay to show activity during sync WASM execution */
#progress-bar::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255,255,255,0.3) 50%,
transparent 100%
);
animation: progress-shimmer 1.5s ease-in-out infinite;
background-size: 200% 100%;
}
@keyframes progress-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Hide shimmer when progress is complete */
#progress-bar.complete::after {
display: none;
}
#progress-percentage {
@@ -267,6 +299,75 @@
50% { opacity: 0.5; }
}
/* Texture Loading Progress Bar (bottom overlay) */
#texture-progress-container {
position: fixed;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
padding: 12px 20px;
border-radius: 8px;
min-width: 300px;
max-width: 400px;
display: none;
z-index: 9999;
border: 1px solid rgba(76, 175, 80, 0.3);
backdrop-filter: blur(10px);
}
#texture-progress-container.visible {
display: block;
}
#texture-progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
#texture-progress-title {
font-size: 13px;
font-weight: 500;
color: #4CAF50;
}
#texture-progress-count {
font-size: 12px;
color: #888;
font-family: monospace;
}
#texture-progress-bar-wrapper {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
height: 6px;
overflow: hidden;
margin-bottom: 6px;
}
#texture-progress-bar {
height: 100%;
width: 100%;
background: linear-gradient(90deg, #4CAF50, #8BC34A);
border-radius: 4px;
transform-origin: left center;
transform: scaleX(0);
will-change: transform;
transition: transform 0.1s ease-out;
}
#texture-progress-details {
font-size: 11px;
color: #666;
text-align: center;
min-height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Help overlay */
#help-overlay {
position: absolute;
@@ -556,6 +657,18 @@
</div>
</div>
<!-- Texture Loading Progress (appears after initial scene render) -->
<div id="texture-progress-container">
<div id="texture-progress-header">
<span id="texture-progress-title">Loading Textures</span>
<span id="texture-progress-count">0/0</span>
</div>
<div id="texture-progress-bar-wrapper">
<div id="texture-progress-bar"></div>
</div>
<div id="texture-progress-details"></div>
</div>
<!-- Help Overlay -->
<div id="help-overlay">
<kbd>Drag & Drop</kbd> USD files to load |

View File

@@ -4,7 +4,7 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { TinyUSDZLoader } from 'tinyusdz/TinyUSDZLoader.js';
import { TinyUSDZLoaderUtils } from 'tinyusdz/TinyUSDZLoaderUtils.js';
import { TinyUSDZLoaderUtils, TextureLoadingManager } from 'tinyusdz/TinyUSDZLoaderUtils.js';
import { setTinyUSDZ as setMaterialXTinyUSDZ } from 'tinyusdz/TinyUSDZMaterialX.js';
import { OpenPBRMaterial } from './OpenPBRMaterial.js';
@@ -17,8 +17,9 @@ const CAMERA_PADDING = 1.2;
// Sample models for testing
const SAMPLE_MODELS = [
'assets/mtlx-normalmap-multi.usdz',
'assets/multi-mesh-test.usda'
//'assets/mtlx-normalmap-multi.usdz',
//'assets/multi-mesh-test.usda'
'assets/WesternDesertTown2-mtlx.usdz'
];
// ============================================================================
@@ -43,7 +44,8 @@ const sceneState = {
materials: [],
textureCount: 0,
meshCount: 0,
upAxis: 'Y'
upAxis: 'Y',
textureLoadingManager: null // For delayed texture loading
};
// Settings
@@ -350,6 +352,92 @@ function resetProgressStages() {
});
progressState.currentStage = null;
progressState.stageProgress = {};
// Reset progress bar to 0
const progressBar = document.getElementById('progress-bar');
progressBar.style.transform = 'scaleX(0)';
progressBar.classList.remove('complete');
document.getElementById('progress-percentage').textContent = '0%';
// Reset throttle timer
lastProgressUpdate = 0;
}
// Track last update time for throttling
let lastProgressUpdate = 0;
const PROGRESS_UPDATE_INTERVAL = 50; // ms - throttle updates to allow browser to paint
/**
* Force browser to schedule a repaint.
* This uses a technique of reading layout properties which forces a reflow,
* combined with requestAnimationFrame queueing.
*/
function forceRepaint() {
// Force layout reflow by reading offsetHeight
void document.body.offsetHeight;
// Queue a microtask to potentially allow compositor to paint
queueMicrotask(() => {
void document.body.offsetHeight;
});
}
// ============================================================================
// Texture Progress UI Functions
// ============================================================================
function showTextureProgress() {
const container = document.getElementById('texture-progress-container');
if (container) {
container.classList.add('visible');
// Reset progress bar
const bar = document.getElementById('texture-progress-bar');
if (bar) bar.style.transform = 'scaleX(0)';
updateTextureProgressUI({ loaded: 0, total: 0, percentage: 0, currentTexture: null });
}
}
function hideTextureProgress() {
const container = document.getElementById('texture-progress-container');
if (container) container.classList.remove('visible');
}
function updateTextureProgressUI(info) {
const { loaded, total, failed, percentage, currentTexture, isComplete } = info;
// Update count
const countEl = document.getElementById('texture-progress-count');
if (countEl) {
const failedText = failed > 0 ? ` (${failed} failed)` : '';
countEl.textContent = `${loaded}/${total}${failedText}`;
}
// Update progress bar
const bar = document.getElementById('texture-progress-bar');
if (bar) {
bar.style.transform = `scaleX(${percentage / 100})`;
}
// Update details
const detailsEl = document.getElementById('texture-progress-details');
if (detailsEl) {
if (isComplete) {
detailsEl.textContent = failed > 0
? `Complete with ${failed} failures`
: 'All textures loaded';
} else if (currentTexture) {
detailsEl.textContent = `Loading: ${currentTexture}`;
} else {
detailsEl.textContent = 'Starting texture loading...';
}
}
// Auto-hide when complete after a delay
if (isComplete) {
setTimeout(() => {
hideTextureProgress();
}, 2000);
}
}
function updateProgressUI(progress) {
@@ -357,16 +445,33 @@ function updateProgressUI(progress) {
const stage = progress.stage;
const message = progress.message || '';
// Update progress bar
document.getElementById('progress-bar').style.width = `${percentage}%`;
// Throttle updates to give browser time to repaint
const now = performance.now();
if (now - lastProgressUpdate < PROGRESS_UPDATE_INTERVAL && percentage < 100) {
// Skip this update, but still record memory
recordMemoryPoint(stage, percentage);
return;
}
lastProgressUpdate = now;
// Update progress bar using transform (can be animated by compositor)
const progressBar = document.getElementById('progress-bar');
progressBar.style.transform = `scaleX(${percentage / 100})`;
// Add 'complete' class when done to stop shimmer animation
if (percentage >= 100 || stage === 'complete') {
progressBar.classList.add('complete');
}
// Update percentage text
document.getElementById('progress-percentage').textContent = `${percentage}%`;
// Update stage text
const stageLabels = {
'downloading': 'Downloading file...',
'parsing': 'Parsing USD...',
'building': 'Building meshes...',
'textures': 'Decoding textures...',
'parsing': 'Parsing USD (WASM)...',
'building': 'Building Three.js scene...',
'textures': 'Processing textures...',
'materials': 'Converting materials...',
'complete': 'Complete!'
};
@@ -378,6 +483,12 @@ function updateProgressUI(progress) {
// Record memory at each progress callback
recordMemoryPoint(stage, percentage);
// Force browser repaint attempt
forceRepaint();
// Log progress for debugging (helps verify callbacks are being called)
console.log(`[Progress] ${stage}: ${percentage}% - ${message}`);
}
function updateStageIcons(currentStage) {
@@ -635,6 +746,14 @@ function cleanupScene() {
sceneState.meshCount = 0;
sceneState.textureCount = 0;
// Abort and reset texture loading manager
if (sceneState.textureLoadingManager) {
sceneState.textureLoadingManager.abort();
sceneState.textureLoadingManager.reset();
sceneState.textureLoadingManager = null;
}
hideTextureProgress();
// Clear WASM memory
if (loaderState.nativeLoader) {
try {
@@ -697,7 +816,12 @@ function disposeMaterial(material) {
// ============================================================================
/**
* Build Three.js scene from USD with detailed progress reporting
* Build Three.js scene from USD with detailed progress reporting.
* This function handles the JS layer processing (Three.js mesh/material/texture creation)
* which CAN show real-time progress since it's async JavaScript (not blocked by WASM).
*
* Uses delayed texture loading mode: scene renders first without textures,
* then textures load in background with separate progress bar.
*/
async function buildSceneWithProgress(usd, onProgress) {
const rootNode = usd.getDefaultRootNode();
@@ -709,32 +833,43 @@ async function buildSceneWithProgress(usd, onProgress) {
sceneState.textureCount = 0;
sceneState.materials = [];
// Create texture loading manager for delayed texture loading
sceneState.textureLoadingManager = new TextureLoadingManager();
// Get mesh/material counts from native loader for accurate progress
const totalMeshes = usd.numMeshes ? usd.numMeshes() : 0;
const totalMaterials = usd.numMaterials ? usd.numMaterials() : 0;
const totalTextures = usd.numTextures ? usd.numTextures() : 0;
let meshesBuilt = 0;
// Phase 1: Building meshes with per-mesh progress
// Phase 1: Building Three.js meshes with per-mesh progress
// This is JS layer processing - progress WILL update in real-time!
// Using delayed texture mode - textures are queued, not loaded immediately
onProgress({
stage: 'building',
percentage: 50,
message: `Building meshes (0/${totalMeshes})...`
message: `Building Three.js meshes (0/${totalMeshes})...`
});
// Allow initial progress to render
await new Promise(r => requestAnimationFrame(r));
const threeRoot = await TinyUSDZLoaderUtils.buildThreeNode(
rootNode,
null,
usd,
{
// Callback for per-mesh progress (if supported)
onMeshBuilt: (meshName, meshIndex) => {
meshesBuilt++;
const progress = 50 + (meshesBuilt / Math.max(1, totalMeshes)) * 20;
// Enable delayed texture loading - textures will be queued
textureLoadingManager: sceneState.textureLoadingManager,
// Progress callback - called during JS layer mesh building
// This WILL show real-time updates because buildThreeNode yields to browser
onProgress: (info) => {
// Map buildThreeNode progress (0-100%) to our range (50-80%)
const mappedPercentage = 50 + (info.percentage * 0.3);
onProgress({
stage: 'building',
percentage: Math.min(70, progress),
message: `Building mesh ${meshesBuilt}/${totalMeshes}: ${meshName || 'mesh'}`
percentage: Math.min(80, mappedPercentage),
message: info.message
});
}
}
@@ -743,14 +878,20 @@ async function buildSceneWithProgress(usd, onProgress) {
// Phase 2: Count meshes and materials from built scene
onProgress({
stage: 'materials',
percentage: 70,
message: `Processing materials (0/${totalMaterials})...`
percentage: 80,
message: `Counting materials...`
});
// Yield to show materials stage
await new Promise(r => requestAnimationFrame(r));
const materialSet = new Set();
let meshIndex = 0;
threeRoot.traverse((obj) => {
if (obj.isMesh) {
sceneState.meshCount++;
meshIndex++;
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => materialSet.add(m));
@@ -763,13 +904,39 @@ async function buildSceneWithProgress(usd, onProgress) {
sceneState.materials = Array.from(materialSet);
// Count textures from materials
sceneState.materials.forEach(mat => {
const textureProps = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap', 'alphaMap'];
// Phase 3: Count textures from materials with progress
onProgress({
stage: 'textures',
percentage: 85,
message: `Counting textures (0/${sceneState.materials.length} materials)...`
});
// Yield to show textures stage
await new Promise(r => requestAnimationFrame(r));
const textureProps = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'emissiveMap', 'aoMap', 'alphaMap'];
let materialIndex = 0;
for (const mat of sceneState.materials) {
materialIndex++;
textureProps.forEach(prop => {
if (mat[prop]) sceneState.textureCount++;
});
});
// Update progress every few materials
if (materialIndex % 5 === 0 || materialIndex === sceneState.materials.length) {
const texProgress = 85 + (materialIndex / sceneState.materials.length) * 10;
onProgress({
stage: 'textures',
percentage: Math.min(95, texProgress),
message: `Processing materials (${materialIndex}/${sceneState.materials.length}), ${sceneState.textureCount} textures found`
});
// Yield periodically to allow UI updates
await new Promise(r => requestAnimationFrame(r));
}
}
// Report final counts
onProgress({
@@ -989,6 +1156,42 @@ async function loadUSDWithProgress(source, isFile = false) {
hideProgress();
// Force a render frame BEFORE starting texture loading
// This ensures the scene without textures is displayed first
await new Promise(r => requestAnimationFrame(r));
threeState.renderer.render(threeState.scene, threeState.camera);
// Start delayed texture loading if there are queued textures
if (sceneState.textureLoadingManager && sceneState.textureLoadingManager.total > 0) {
const texManager = sceneState.textureLoadingManager;
const totalTextures = texManager.total;
console.log(`[Progress Demo] Starting delayed texture loading: ${totalTextures} textures queued`);
showTextureProgress();
// Start texture loading with progress callbacks
// This runs asynchronously - scene is already visible and interactive
texManager.startLoading({
onProgress: updateTextureProgressUI,
onTextureLoaded: (material, mapProperty, texture) => {
// Material is automatically updated by the manager
// Force re-render to show the new texture
material.needsUpdate = true;
},
concurrency: 2, // Load 2 textures at a time
yieldInterval: 16 // Yield to browser every frame (~60fps)
}).then(status => {
console.log(`[Progress Demo] Texture loading complete: ${status.loaded}/${status.total} loaded, ${status.failed} failed`);
// Update texture count in model info
sceneState.textureCount = status.loaded;
document.getElementById('texture-count').textContent = sceneState.textureCount;
}).catch(err => {
console.error('[Progress Demo] Texture loading error:', err);
hideTextureProgress();
});
}
} catch (error) {
console.error('Failed to load USD:', error);
updateStatus(`Error: ${error.message}`);

View File

@@ -6,15 +6,264 @@ import { LoaderUtils } from "three"
import { convertOpenPBRToMeshPhysicalMaterialLoaded } from './TinyUSDZMaterialX.js';
import { decodeEXR as decodeEXRWithFallback } from './EXRDecoder.js';
/**
* TextureLoadingManager - Manages delayed/progressive texture loading.
*
* This allows the scene to render immediately with basic materials,
* while textures load in the background with progress reporting.
*
* Usage:
* const manager = new TextureLoadingManager();
*
* // Queue texture tasks during material setup
* manager.queueTexture(material, 'map', textureId, usdScene);
*
* // Start loading after scene is rendered
* await manager.startLoading({
* onProgress: (info) => console.log(`${info.loaded}/${info.total} textures`),
* concurrency: 2, // Load 2 textures at a time
* yieldInterval: 16 // Yield to browser every 16ms
* });
*/
class TextureLoadingManager {
constructor() {
this.queue = []; // Pending texture tasks
this.loaded = 0; // Number of loaded textures
this.failed = 0; // Number of failed textures
this.total = 0; // Total textures to load
this.isLoading = false; // Loading in progress
this.aborted = false; // Loading was aborted
}
/**
* Queue a texture to be loaded later
* @param {THREE.Material} material - Target material
* @param {string} mapProperty - Property name (e.g., 'map', 'normalMap')
* @param {number} textureId - USD texture ID
* @param {Object} usdScene - USD scene/loader instance
* @param {Object} options - Additional options (e.g., normalScale)
*/
queueTexture(material, mapProperty, textureId, usdScene, options = {}) {
this.queue.push({
material,
mapProperty,
textureId,
usdScene,
options,
status: 'pending'
});
this.total = this.queue.length;
}
/**
* Get current loading status
*/
getStatus() {
return {
total: this.total,
loaded: this.loaded,
failed: this.failed,
pending: this.total - this.loaded - this.failed,
percentage: this.total > 0 ? (this.loaded / this.total) * 100 : 0,
isLoading: this.isLoading,
isComplete: this.loaded + this.failed >= this.total && !this.isLoading
};
}
/**
* Abort loading
*/
abort() {
this.aborted = true;
}
/**
* Reset manager for new loading session
*/
reset() {
this.queue = [];
this.loaded = 0;
this.failed = 0;
this.total = 0;
this.isLoading = false;
this.aborted = false;
}
/**
* Start loading queued textures with progress reporting
* @param {Object} options - Loading options
* @param {Function} options.onProgress - Progress callback ({loaded, total, percentage, currentTexture})
* @param {Function} options.onTextureLoaded - Called when each texture loads (material, mapProperty, texture)
* @param {number} options.concurrency - Number of concurrent loads (default: 1)
* @param {number} options.yieldInterval - ms between browser yields (default: 16)
* @returns {Promise<Object>} - Final status {loaded, failed, total}
*/
async startLoading(options = {}) {
const {
onProgress = null,
onTextureLoaded = null,
concurrency = 1,
yieldInterval = 16
} = options;
if (this.isLoading) {
console.warn('TextureLoadingManager: Already loading');
return this.getStatus();
}
this.isLoading = true;
this.aborted = false;
let lastYieldTime = performance.now();
// Report initial progress
if (onProgress) {
onProgress({
loaded: 0,
total: this.total,
percentage: 0,
currentTexture: null
});
}
// Yield to allow initial render without textures
await new Promise(r => requestAnimationFrame(r));
// Process queue with concurrency control
const pendingTasks = [...this.queue];
const activeTasks = new Set();
const loadTexture = async (task) => {
if (this.aborted) return;
task.status = 'loading';
const { material, mapProperty, textureId, usdScene, options: taskOptions } = task;
try {
const texture = await TinyUSDZLoaderUtils.getTextureFromUSD(usdScene, textureId);
if (texture && !this.aborted) {
material[mapProperty] = texture;
// Apply special options (e.g., normal map scale)
if (taskOptions.normalScale && mapProperty === 'normalMap' && material.normalScale) {
material.normalScale.set(taskOptions.normalScale, taskOptions.normalScale);
}
material.needsUpdate = true;
task.status = 'loaded';
this.loaded++;
if (onTextureLoaded) {
onTextureLoaded(material, mapProperty, texture);
}
}
} catch (err) {
console.warn(`Failed to load texture ${textureId} for ${mapProperty}:`, err.message);
task.status = 'failed';
this.failed++;
}
// Report progress
if (onProgress && !this.aborted) {
onProgress({
loaded: this.loaded,
failed: this.failed,
total: this.total,
percentage: (this.loaded / this.total) * 100,
currentTexture: `${mapProperty} (${textureId})`
});
}
// Yield to browser periodically
const now = performance.now();
if (now - lastYieldTime >= yieldInterval) {
lastYieldTime = now;
await new Promise(r => requestAnimationFrame(r));
}
};
// Process with concurrency limit
while (pendingTasks.length > 0 || activeTasks.size > 0) {
if (this.aborted) break;
// Start new tasks up to concurrency limit
while (pendingTasks.length > 0 && activeTasks.size < concurrency) {
const task = pendingTasks.shift();
const promise = loadTexture(task).then(() => {
activeTasks.delete(promise);
});
activeTasks.add(promise);
}
// Wait for at least one task to complete
if (activeTasks.size > 0) {
await Promise.race(activeTasks);
}
}
this.isLoading = false;
// Final progress report
if (onProgress) {
onProgress({
loaded: this.loaded,
failed: this.failed,
total: this.total,
percentage: 100,
currentTexture: null,
isComplete: true
});
}
return this.getStatus();
}
}
// Export the manager class
export { TextureLoadingManager };
class TinyUSDZLoaderUtils extends LoaderUtils {
// Static reference to TinyUSDZ WASM module for EXR fallback
static _tinyusdz = null;
// Yield interval for UI updates (ms)
static YIELD_INTERVAL_MS = 16; // ~60fps
constructor() {
super();
}
/**
* Yield to browser to allow UI repaint during long-running async operations.
* Uses requestAnimationFrame for optimal frame timing.
* @returns {Promise<void>}
*/
static yieldToUI() {
return new Promise(resolve => {
// Use requestAnimationFrame for smoother updates
// Falls back to setTimeout if RAF is not available
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve());
} else {
setTimeout(resolve, 0);
}
});
}
/**
* Conditional yield - only yields if enough time has passed since last yield
* @param {Object} state - State object with lastYieldTime property
* @returns {Promise<void>}
*/
static async maybeYieldToUI(state) {
const now = performance.now();
if (!state.lastYieldTime || (now - state.lastYieldTime) >= this.YIELD_INTERVAL_MS) {
state.lastYieldTime = now;
await this.yieldToUI();
}
}
/**
* Set TinyUSDZ WASM module for EXR decoding fallback
* @param {Object} tinyusdz - TinyUSDZ WASM module instance
@@ -275,33 +524,47 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
// - [x] clearcoat -> clearcoat
// - [x] clearcoatRoughness -> clearcoatRoughness
// - [x] specularColor -> specular
// - [x] roughness -> roughness
// - [x] roughness -> roughness
// - [x] metallic -> metalness
// - [x] emissiveColor -> emissive
// - [x] opacity -> opacity (TODO: map to .transmission?)
// - [x] occlusion -> aoMap
// - [x] normal -> normalMap
// - [x] displacement -> displacementMap
static convertUsdMaterialToMeshPhysicalMaterial(usdMaterial, usdScene) {
//
// Options:
// - textureLoadingManager: TextureLoadingManager instance for delayed texture loading
// If provided, textures are queued instead of loaded immediately
//
static convertUsdMaterialToMeshPhysicalMaterial(usdMaterial, usdScene, options = {}) {
const material = new THREE.MeshPhysicalMaterial();
const loader = new THREE.TextureLoader();
const textureManager = options.textureLoadingManager || null;
// Helper to load texture immediately or queue for later
const loadOrQueueTexture = (mapProperty, textureId, textureOptions = {}) => {
if (textureManager) {
// Delayed mode: queue texture for later loading
textureManager.queueTexture(material, mapProperty, textureId, usdScene, textureOptions);
} else {
// Immediate mode: load texture now (original behavior)
this.getTextureFromUSD(usdScene, textureId).then((texture) => {
material[mapProperty] = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error(`failed to load ${mapProperty} texture:`, err);
});
}
};
// Diffuse color and texture
material.color = new THREE.Color(0.18, 0.18, 0.18);
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'diffuseColor')) {
const color = usdMaterial.diffuseColor;
material.color = new THREE.Color(color[0], color[1], color[2]);
//console.log("diffuseColor:", material.color);
}
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'diffuseColorTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.diffuseColorTextureId).then((texture) => {
//console.log("gettex");
material.map = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load texture. uri not exists or Cross-Site origin header is not set in the web server?", err);
});
loadOrQueueTexture('map', usdMaterial.diffuseColorTextureId);
}
// IOR
@@ -334,12 +597,7 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
material.specularColor = new THREE.Color(color[0], color[1], color[2]);
}
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'specularColorTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.specularColorTextureId).then((texture) => {
material.specularColorMap = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load specular color texture", err);
});
loadOrQueueTexture('specularColorMap', usdMaterial.specularColorTextureId);
}
} else {
material.metalness = 0.0;
@@ -347,12 +605,7 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
material.metalness = usdMaterial.metallic;
}
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'metallicTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.metallicTextureId).then((texture) => {
material.metalnessMap = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load metallic texture", err);
});
loadOrQueueTexture('metalnessMap', usdMaterial.metallicTextureId);
}
}
@@ -362,12 +615,7 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
material.roughness = usdMaterial.roughness;
}
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'roughnessTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.roughnessTextureId).then((texture) => {
material.roughnessMap = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load roughness texture", err);
});
loadOrQueueTexture('roughnessMap', usdMaterial.roughnessTextureId);
}
// Emissive
@@ -376,12 +624,7 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
material.emissive = new THREE.Color(color[0], color[1], color[2]);
}
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'emissiveColorTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.emissiveColorTextureId).then((texture) => {
material.emissiveMap = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load emissive texture", err);
});
loadOrQueueTexture('emissiveMap', usdMaterial.emissiveColorTextureId);
}
// Opacity
@@ -393,46 +636,22 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
}
}
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'opacityTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.opacityTextureId).then((texture) => {
material.alphaMap = texture;
// FIXME. disable opacity texture for a while.
// transparent = true will create completely transparent material for some reason.
//material.transparent = true;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load opacity texture", err);
});
loadOrQueueTexture('alphaMap', usdMaterial.opacityTextureId);
}
// Ambient Occlusion
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'occlusionTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.occlusionTextureId).then((texture) => {
material.aoMap = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load occlusion texture", err);
});
loadOrQueueTexture('aoMap', usdMaterial.occlusionTextureId);
}
// Normal Map
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'normalTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.normalTextureId).then((texture) => {
material.normalMap = texture;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load normal texture", err);
});
loadOrQueueTexture('normalMap', usdMaterial.normalTextureId);
}
// Displacement Map
if (Object.prototype.hasOwnProperty.call(usdMaterial, 'displacementTextureId')) {
this.getTextureFromUSD(usdScene, usdMaterial.displacementTextureId).then((texture) => {
material.displacementMap = texture;
material.displacementScale = 1.0;
material.needsUpdate = true;
}).catch((err) => {
console.error("failed to load displacement texture", err);
});
loadOrQueueTexture('displacementMap', usdMaterial.displacementTextureId, { displacementScale: 1.0 });
}
return material;
@@ -546,7 +765,7 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
if (parsedMaterial && parsedMaterial.hasUsdPreviewSurface) {
// Extract surfaceShader data from the JSON structure
const shaderData = parsedMaterial.surfaceShader || parsedMaterial;
return this.convertUsdMaterialToMeshPhysicalMaterial(shaderData, usdScene);
return this.convertUsdMaterialToMeshPhysicalMaterial(shaderData, usdScene, options);
}
return this.createDefaultMaterial();
}
@@ -556,7 +775,8 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
const material = await convertOpenPBRToMeshPhysicalMaterialLoaded(parsedMaterial, usdScene, {
envMap: options.envMap || null,
envMapIntensity: options.envMapIntensity || 1.0,
textureCache: options.textureCache || new Map()
textureCache: options.textureCache || new Map(),
textureLoadingManager: options.textureLoadingManager || null
});
// Apply sideness based on USD doubleSided attribute
@@ -661,7 +881,8 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
// Extract surfaceShader data from the JSON structure
// The JSON format nests shader properties under surfaceShader
const shaderData = parsedMaterial.surfaceShader || parsedMaterial;
return this.convertUsdMaterialToMeshPhysicalMaterial(shaderData, usdScene);
// Pass options through to support textureLoadingManager
return this.convertUsdMaterialToMeshPhysicalMaterial(shaderData, usdScene, options);
}
return this.createDefaultMaterial();
@@ -772,7 +993,8 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
envMap: options.envMap || null,
envMapIntensity: options.envMapIntensity || 1.0,
textureCache: options.textureCache || new Map(),
doubleSided: geometry.userData['doubleSided']
doubleSided: geometry.userData['doubleSided'],
textureLoadingManager: options.textureLoadingManager || null
});
// Store material metadata for UI and reloading
@@ -842,7 +1064,8 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
envMap: options.envMap || null,
envMapIntensity: options.envMapIntensity || 1.0,
textureCache: options.textureCache || new Map(),
doubleSided: geometry.userData['doubleSided']
doubleSided: geometry.userData['doubleSided'],
textureLoadingManager: options.textureLoadingManager || null
});
material.envMap = options.envMap || null;
@@ -910,14 +1133,30 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
return count;
}
/**
* Count total meshes in USD hierarchy
* @private
*/
static _countMeshes(usdNode) {
let count = usdNode.nodeType === 'mesh' ? 1 : 0;
if (usdNode.children) {
for (const child of usdNode.children) {
count += this._countMeshes(child);
}
}
return count;
}
// Supported options:
// - 'overrideMaterial' : Override usd material with defaultMtl.
// - 'onProgress' : Progress callback (info) => void
// info: { stage: 'building', percentage: number, message: string }
// info: { stage: 'building'|'textures', percentage: number, message: string }
// - '_progressState' : Internal state for progress tracking (auto-created)
/**
* Build a Three.js scene graph from a USD node hierarchy
* Includes browser yields to allow UI updates during scene building.
*
* @param {Object} usdNode - USD node from TinyUSDZLoader
* @param {THREE.Material} defaultMtl - Default material to use
* @param {Object} usdScene - USD scene object (TinyUSDZLoaderNative)
@@ -931,18 +1170,24 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
// Initialize progress tracking on first call (root node)
if (!options._progressState) {
const totalNodes = this._countNodes(usdNode);
const totalMeshes = this._countMeshes(usdNode);
options._progressState = {
processedNodes: 0,
totalNodes: totalNodes
processedMeshes: 0,
totalNodes: totalNodes,
totalMeshes: totalMeshes,
lastYieldTime: 0
};
// Report initial progress
if (options.onProgress) {
options.onProgress({
stage: 'building',
percentage: 0,
message: `Building scene (0/${totalNodes} nodes)...`
message: `Building scene (0/${totalMeshes} meshes)...`
});
}
// Initial yield to show progress UI
await this.yieldToUI();
}
var node = new THREE.Group();
@@ -953,39 +1198,43 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
// intermediate xform node
// Apply the USD local transform matrix to the Three.js node
const matrix = this.toMatrix4(usdNode.localMatrix);
//console.log(" Applied localMatrix:", {
// matrix: matrix});
// Decompose the matrix into position, rotation, and scale
// This is necessary for Three.js to properly handle the transform
node.applyMatrix4(matrix);
// Log transform for debugging
//console.log(" Applied xform matrix:", {
// position: [node.position.x, node.position.y, node.position.z],
// rotation: [node.rotation.x, node.rotation.y, node.rotation.z],
// scale: [node.scale.x, node.scale.y, node.scale.z]
//});
} else if (usdNode.nodeType == 'mesh') {
// contentId is the mesh ID in the USD scene.
const mesh = usdScene.getMesh(usdNode.contentId);
// Update progress before building mesh
if (options._progressState && options.onProgress) {
const { processedMeshes, totalMeshes } = options._progressState;
const percentage = (processedMeshes / Math.max(1, totalMeshes)) * 100;
options.onProgress({
stage: 'building',
percentage: percentage,
message: `Building mesh ${processedMeshes + 1}/${totalMeshes}: ${usdNode.primName}`
});
}
// Yield to browser before heavy mesh setup
await this.maybeYieldToUI(options._progressState);
const threeMesh = await this.setupMesh(mesh, defaultMtl, usdScene, options);
node = threeMesh;
// Increment mesh counter after building
if (options._progressState) {
options._progressState.processedMeshes++;
}
// Apply transform to mesh nodes as well
// Mesh nodes can also have transforms in USD
if (usdNode.localMatrix) {
const matrix = this.toMatrix4(usdNode.localMatrix);
node.applyMatrix4(matrix);
//console.log(" Applied mesh matrix:", {
// position: [node.position.x, node.position.y, node.position.z],
// rotation: [node.rotation.x, node.rotation.y, node.rotation.z],
// scale: [node.scale.x, node.scale.y, node.scale.z]
//});
}
} else {
@@ -1003,16 +1252,9 @@ class TinyUSDZLoaderUtils extends LoaderUtils {
// Update progress after processing this node
if (options._progressState) {
options._progressState.processedNodes++;
const { processedNodes, totalNodes } = options._progressState;
const percentage = (processedNodes / totalNodes) * 100;
if (options.onProgress) {
options.onProgress({
stage: 'building',
percentage: percentage,
message: `Building: ${usdNode.primName} (${processedNodes}/${totalNodes})`
});
}
// Yield periodically to allow UI updates
await this.maybeYieldToUI(options._progressState);
}
if (Object.prototype.hasOwnProperty.call(usdNode, 'children')) {

View File

@@ -435,8 +435,9 @@ async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene
return material;
}
// Texture cache
// Texture cache and delayed loading manager
const textureCache = options.textureCache || new Map();
const textureManager = options.textureLoadingManager || null;
// Helper to apply parameter with optional texture
const applyParam = async (paramName, paramValue, group = null) => {
@@ -456,14 +457,22 @@ async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene
if (usdScene && hasTexture(paramValue)) {
const texMapName = OPENPBR_TEXTURE_MAP[paramName];
if (texMapName) {
const texture = await loadTextureFromUSD(usdScene, getTextureId(paramValue), textureCache);
if (texture) {
material[texMapName] = texture;
material.userData.textures[texMapName] = {
textureId: getTextureId(paramValue),
texture: texture
};
material.needsUpdate = true;
const textureId = getTextureId(paramValue);
// If textureLoadingManager is provided, queue texture for later loading
if (textureManager) {
textureManager.queueTexture(material, texMapName, textureId, usdScene);
} else {
// Load immediately (original behavior)
const texture = await loadTextureFromUSD(usdScene, textureId, textureCache);
if (texture) {
material[texMapName] = texture;
material.userData.textures[texMapName] = {
textureId: textureId,
texture: texture
};
material.needsUpdate = true;
}
}
}
}
@@ -521,10 +530,15 @@ async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene
}
// Load emission texture
if (usdScene && hasTexture(flat.emission_color)) {
const texture = await loadTextureFromUSD(usdScene, getTextureId(flat.emission_color), textureCache);
if (texture) {
material.emissiveMap = texture;
material.userData.textures.emissiveMap = { textureId: getTextureId(flat.emission_color), texture };
const textureId = getTextureId(flat.emission_color);
if (textureManager) {
textureManager.queueTexture(material, 'emissiveMap', textureId, usdScene);
} else {
const texture = await loadTextureFromUSD(usdScene, textureId, textureCache);
if (texture) {
material.emissiveMap = texture;
material.userData.textures.emissiveMap = { textureId, texture };
}
}
}
}
@@ -541,11 +555,17 @@ async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene
material.transparent = opacityValue < 1.0;
}
if (usdScene && hasTexture(opacityParam)) {
const texture = await loadTextureFromUSD(usdScene, getTextureId(opacityParam), textureCache);
if (texture) {
material.alphaMap = texture;
material.transparent = true;
material.userData.textures.alphaMap = { textureId: getTextureId(opacityParam), texture };
const textureId = getTextureId(opacityParam);
// For alpha maps, we need to set transparent=true even in delayed mode
material.transparent = true;
if (textureManager) {
textureManager.queueTexture(material, 'alphaMap', textureId, usdScene);
} else {
const texture = await loadTextureFromUSD(usdScene, textureId, textureCache);
if (texture) {
material.alphaMap = texture;
material.userData.textures.alphaMap = { textureId, texture };
}
}
}
}
@@ -553,11 +573,17 @@ async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene
// Normal map
const normalParam = flat.normal !== undefined ? flat.normal : flat.geometry_normal;
if (normalParam !== undefined && usdScene && hasTexture(normalParam)) {
const texture = await loadTextureFromUSD(usdScene, getTextureId(normalParam), textureCache);
if (texture) {
material.normalMap = texture;
material.normalScale = new THREE.Vector2(1, 1);
material.userData.textures.normalMap = { textureId: getTextureId(normalParam), texture };
const textureId = getTextureId(normalParam);
// Initialize normalScale even in delayed mode
material.normalScale = new THREE.Vector2(1, 1);
if (textureManager) {
textureManager.queueTexture(material, 'normalMap', textureId, usdScene);
} else {
const texture = await loadTextureFromUSD(usdScene, textureId, textureCache);
if (texture) {
material.normalMap = texture;
material.userData.textures.normalMap = { textureId, texture };
}
}
}
}
@@ -622,10 +648,15 @@ async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene
material.emissive = createColor(emissionColor);
}
if (usdScene && hasTexture(pbr.emission.emission_color)) {
const texture = await loadTextureFromUSD(usdScene, getTextureId(pbr.emission.emission_color), textureCache);
if (texture) {
material.emissiveMap = texture;
material.userData.textures.emissiveMap = { textureId: getTextureId(pbr.emission.emission_color), texture };
const textureId = getTextureId(pbr.emission.emission_color);
if (textureManager) {
textureManager.queueTexture(material, 'emissiveMap', textureId, usdScene);
} else {
const texture = await loadTextureFromUSD(usdScene, textureId, textureCache);
if (texture) {
material.emissiveMap = texture;
material.userData.textures.emissiveMap = { textureId, texture };
}
}
}
if (pbr.emission.emission_luminance !== undefined) {
@@ -643,22 +674,32 @@ async function convertOpenPBRToMeshPhysicalMaterialLoaded(materialData, usdScene
material.transparent = opacityValue < 1.0;
}
if (usdScene && hasTexture(opacityParam)) {
const texture = await loadTextureFromUSD(usdScene, getTextureId(opacityParam), textureCache);
if (texture) {
material.alphaMap = texture;
material.transparent = true;
material.userData.textures.alphaMap = { textureId: getTextureId(opacityParam), texture };
const textureId = getTextureId(opacityParam);
material.transparent = true;
if (textureManager) {
textureManager.queueTexture(material, 'alphaMap', textureId, usdScene);
} else {
const texture = await loadTextureFromUSD(usdScene, textureId, textureCache);
if (texture) {
material.alphaMap = texture;
material.userData.textures.alphaMap = { textureId, texture };
}
}
}
}
const normalParam = pbr.geometry.normal !== undefined ? pbr.geometry.normal : pbr.geometry.geometry_normal;
if (normalParam !== undefined && usdScene && hasTexture(normalParam)) {
const texture = await loadTextureFromUSD(usdScene, getTextureId(normalParam), textureCache);
if (texture) {
material.normalMap = texture;
material.normalScale = new THREE.Vector2(1, 1);
material.userData.textures.normalMap = { textureId: getTextureId(normalParam), texture };
const textureId = getTextureId(normalParam);
material.normalScale = new THREE.Vector2(1, 1);
if (textureManager) {
textureManager.queueTexture(material, 'normalMap', textureId, usdScene);
} else {
const texture = await loadTextureFromUSD(usdScene, textureId, textureCache);
if (texture) {
material.normalMap = texture;
material.userData.textures.normalMap = { textureId, texture };
}
}
}
}