| id | asyncloadingsystem |
|---|---|
| title | Async Loading System |
| sidebar_position | 9 |
The async loading system allows you to load USDZ files with many models without blocking the main thread. The engine remains responsive during loading, and you can track progress for UI feedback.
✅ Non-blocking loading - Engine continues running while assets load
✅ Progress tracking - Monitor loading progress per entity or globally
✅ Fallback meshes - Automatically loads a cube if loading fails
✅ Backward compatible - Old synchronous APIs still work
✅ Scene readiness guard - Pause interaction while scene setup is unsafe
The engine now provides global scene readiness APIs:
setSceneReady(_ ready: Bool)
isSceneReady() -> BoolUse them to mark whether interaction systems should run safely.
- Use
setSceneReady(false)before long or multi-step scene mutations. - Use
setSceneReady(true)after the scene is stable. - Guard your input/update interaction paths with
isSceneReady().
For standard setEntityMeshAsync(...) / streaming paths, the engine already uses loading gates internally.
You mainly need setSceneReady(...) for custom workflows outside those built-in paths.
setSceneReady(false)
let root = createEntity()
setEntityMeshAsync(entityId: root, filename: "large_model", withExtension: "usdz") { isOutOfCore in
if isOutOfCore {
// Out-of-core: enable streaming so stubs start uploading
GeometryStreamingSystem.shared.enabled = true
enableStreaming(entityId: root, streamingRadius: 200, unloadRadius: 350, priority: 10)
}
// custom post-load work: hierarchy edits, batching, metadata pass, etc.
setSceneReady(true)
}func handleInput() {
guard isSceneReady() else { return }
// safe input logic
}// Create entity
let entityId = createEntity()
// Load mesh asynchronously (fire and forget)
setEntityMeshAsync(
entityId: entityId,
filename: "large_model",
withExtension: "usdz"
)
// Engine continues running - mesh appears when loadedThe completion Bool indicates how the asset was routed:
true— asset was registered as out-of-core stubs;GeometryStreamingSystemmust be enabled for anything to render.false— asset used the immediate path (all meshes GPU-resident) or loading failed (entity has a fallback cube).
let entityId = createEntity()
setEntityMeshAsync(
entityId: entityId,
filename: "large_model",
withExtension: "usdz"
) { isOutOfCore in
if isOutOfCore {
// Asset was registered as streaming stubs — enable the system to start uploads.
GeometryStreamingSystem.shared.enabled = true
enableStreaming(entityId: entityId, streamingRadius: 200, unloadRadius: 350, priority: 10)
} else {
print("Model loaded immediately (small asset or error fallback)")
}
}By default the engine automatically selects the loading path using AssetProfiler. You can override this with the streamingPolicy parameter:
// Always use the stub path — good for assets you know are large
setEntityMeshAsync(
entityId: entityId,
filename: "huge_city",
withExtension: "usdz",
streamingPolicy: .outOfCore
)
// Always upload immediately — good for small, always-visible assets
setEntityMeshAsync(
entityId: entityId,
filename: "small_prop",
withExtension: "usdz",
streamingPolicy: .immediate
)
// Let the engine decide (default — recommended for most cases)
setEntityMeshAsync(
entityId: entityId,
filename: "model",
withExtension: "usdz",
streamingPolicy: .auto
)| Policy | Behavior |
|---|---|
.auto (default) |
AssetProfiler estimates geometry and texture bytes vs. the platform budget and independently selects .streaming or .eager for each domain |
.outOfCore |
Always stubs + geometry streaming; completion returns true |
.immediate |
Always direct GPU upload; completion returns false |
For .auto, the engine logs the classification so you can see exactly why each decision was made:
[AssetProfiler] 'dungeon3' (2.1 MB) → mixed | geo ~2.9 MB, tex ~6.2 MB | budget: 1024 MB | meshes: 410
[AssetProfiler] Policy → geometry: streaming, texture: eager (source: auto)
loadSceneAsync(
filename: "complex_scene",
withExtension: "usdz"
) { success in
if success {
print("Scene loaded with all entities")
}
}Task {
let isLoading = await AssetLoadingState.shared.isLoadingAny()
if isLoading {
print("Assets are currently loading...")
}
}Task {
let count = await AssetLoadingState.shared.loadingCount()
print("Loading \(count) entities")
}Task {
let (current, total) = await AssetLoadingState.shared.totalProgress()
let percentage = Float(current) / Float(total) * 100
print("Progress: \(percentage)% (\(current)/\(total) meshes)")
}Task {
let summary = await AssetLoadingState.shared.loadingSummary()
print(summary) // "Loading large_model.usdz: 5/20 meshes"
}Task {
if let progress = await AssetLoadingState.shared.getProgress(for: entityId) {
print("\(progress.filename): \(progress.currentMesh)/\(progress.totalMeshes)")
print("Percentage: \(progress.percentage * 100)%")
}
}// In your editor's update loop or UI
Task {
if await AssetLoadingState.shared.isLoadingAny() {
let summary = await AssetLoadingState.shared.loadingSummary()
// Show loading indicator with summary text
showLoadingIndicator(text: summary)
} else {
// Hide loading indicator
hideLoadingIndicator()
}
}Task {
let allProgress = await AssetLoadingState.shared.getAllProgress()
for progress in allProgress {
let percentage = progress.percentage * 100
print("📦 \(progress.filename): \(Int(percentage))%")
// Update UI progress bar
updateProgressBar(
id: progress.entityId,
filename: progress.filename,
percentage: percentage
)
}
}When async loading fails, a fallback cube mesh is automatically loaded:
setEntityMeshAsync(
entityId: entityId,
filename: "missing_file",
withExtension: "usdz"
) { success in
if !success {
// Entity now has a cube mesh as fallback
print("Loaded fallback cube")
}
}setEntityMeshAsync(
entityId: entityId,
filename: "model",
withExtension: "usdz"
) { success in
if !success {
// Handle error - entity has fallback cube
showErrorDialog("Failed to load model.usdz")
// Optionally destroy entity or retry
// destroyEntity(entityId: entityId)
}
}The old synchronous APIs still work unchanged:
// Old way (blocks main thread)
setEntityMesh(
entityId: entityId,
filename: "model",
withExtension: "usdz"
)
// New way (non-blocking)
setEntityMeshAsync(
entityId: entityId,
filename: "model",
withExtension: "usdz"
)let entity1 = createEntity()
let entity2 = createEntity()
let entity3 = createEntity()
// All three load in parallel without blocking
setEntityMeshAsync(entityId: entity1, filename: "model1", withExtension: "usdz")
setEntityMeshAsync(entityId: entity2, filename: "model2", withExtension: "usdz")
setEntityMeshAsync(entityId: entity3, filename: "model3", withExtension: "usdz")
// Track total progress
Task {
while await AssetLoadingState.shared.isLoadingAny() {
let summary = await AssetLoadingState.shared.loadingSummary()
print(summary)
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
}
print("All models loaded!")
}setEntityMeshAsync(
entityId: entityId,
filename: "blender_model",
withExtension: "usdz",
coordinateConversion: .forceZUpToYUp
)- Reading USDZ file from disk
- Parsing MDL data structures
- Loading texture data
- Applying coordinate transformations
- Creating Metal resources (MTKMesh, MTLTexture)
- Registering ECS components
- Updating entity transforms
- Creating fallback meshes on error
All AssetLoadingState operations are thread-safe via Swift's actor model. You can safely query loading state from any thread.
- Prefer async for large files (>20 models or >50MB)
- Use sync for small files (1-5 models, <10MB) - less overhead
- Batch loads together - loading multiple files in parallel is efficient
- Monitor progress - use for UI feedback during long loads
func loadGameAssets() {
let entity = createEntity()
setEntityMesh(entityId: entity, filename: "huge_scene", withExtension: "usdz")
// Engine was frozen here ❌
}func loadGameAssets() {
let entity = createEntity()
setEntityMeshAsync(entityId: entity, filename: "huge_scene", withExtension: "usdz") {
success in
if success {
print("Scene ready!")
}
}
// Engine continues running ✅
}