playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
258 lines (255 loc) • 9.63 kB
JavaScript
import { Debug } from '../../../core/debug.js';
import { Asset } from '../../asset/asset.js';
import { GSplatAssetLoaderBase } from '../../../scene/gsplat-unified/gsplat-asset-loader-base.js';
/**
* @import { AssetRegistry } from '../../asset/asset-registry.js'
*/ /**
* A utility class for programmatically loading and unloading gsplat resources. This class provides
* a simple interface for loading gsplat assets dynamically and manages their lifecycle, including
* keeping track of loaded assets for efficient unloading.
*
* @category Asset
* @ignore
*/ class GSplatAssetLoader extends GSplatAssetLoaderBase {
/**
* Destroys the asset loader and force-unloads all tracked assets, ignoring ref counts.
* This is used when the octree resource itself is being destroyed.
*/ destroy() {
this._destroyed = true;
// Force-unload all tracked assets
for (const asset of this._urlToAsset.values()){
// Fire 'unload' event to trigger cleanup in parsers (like sog.js)
asset.fire('unload', asset);
// Remove event listeners
asset.off('load');
asset.off('error');
this._registry.remove(asset);
asset.unload();
}
this._urlToAsset.clear();
this._loadQueue.length = 0;
this._currentlyLoading.clear();
this._retryCount.clear();
}
/**
* Checks if the loader can start new loads. Returns false if the 'gsplat' handler
* has been removed from the registry (e.g., during app destruction).
*
* @returns {boolean} True if loading is possible, false otherwise.
* @private
*/ _canLoad() {
return !!this._registry.loader?.getHandler('gsplat');
}
/**
* Initiates loading of a gsplat asset. This is a fire-and-forget operation that starts
* the loading process. Use getResource() later to check if the asset has finished loading.
*
* @param {string} url - The URL of the gsplat file to load.
*/ load(url) {
Debug.assert(url);
// Skip if already loading or loaded
const asset = this._urlToAsset.get(url);
if (asset?.loaded || this._currentlyLoading.has(url)) {
return;
}
// Skip if already queued
if (this._loadQueue.includes(url)) {
return;
}
// If under concurrent limit, start loading immediately
if (this._currentlyLoading.size < this.maxConcurrentLoads) {
this._startLoading(url);
} else {
// Otherwise, add to queue
this._loadQueue.push(url);
}
}
/**
* Starts loading an asset immediately.
*
* @param {string} url - The URL of the gsplat file to load.
* @private
*/ _startLoading(url) {
// Add to currently loading set
this._currentlyLoading.add(url);
// Get or create asset
let asset = this._urlToAsset.get(url);
if (!asset) {
// Create a new gsplat asset
// @ts-ignore - minimalMemory is a custom option for gsplat assets
asset = new Asset(url, 'gsplat', {
url
}, {}, {
minimalMemory: true
});
// Assert that registry doesn't already have an asset for this URL
// If it does, there's a code ownership issue - GSplatAssetLoader should be the only
// creator of gsplat assets with these URLs
Debug.assert(!this._registry.getByUrl(url), `Asset with URL ${url} already exists in registry but not tracked by GSplatAssetLoader`);
this._registry.add(asset);
// Track this asset in our map
this._urlToAsset.set(url, asset);
}
// Attach event listeners
asset.once('load', ()=>this._onAssetLoadSuccess(url, asset));
asset.once('error', (err)=>this._onAssetLoadError(url, asset, err));
// Start loading the asset
if (!asset.loaded && !asset.loading) {
this._registry.load(asset);
}
}
/**
* Called when an asset successfully loads.
*
* @param {string} url - The URL of the loaded asset.
* @param {Asset} asset - The loaded asset.
* @private
*/ _onAssetLoadSuccess(url, asset) {
// Don't process if destroyed or already unloaded
if (this._destroyed || !this._urlToAsset.has(url)) {
return;
}
// Remove from currently loading
this._currentlyLoading.delete(url);
// Clear retry count
this._retryCount.delete(url);
// Process next item in queue
this._processQueue();
}
/**
* Called when an asset fails to load.
*
* @param {string} url - The URL of the failed asset.
* @param {Asset} asset - The asset that failed to load.
* @param {string|Error} err - The error that occurred.
* @private
*/ _onAssetLoadError(url, asset, err) {
// Don't process if destroyed, handler removed, or already unloaded
if (this._destroyed || !this._canLoad() || !this._urlToAsset.has(url)) {
return;
}
const retryCount = this._retryCount.get(url) || 0;
if (retryCount < this.maxRetries) {
// Increment retry count
this._retryCount.set(url, retryCount + 1);
// Reset asset state for retry
asset.loaded = false;
asset.loading = false;
// Retry loading
Debug.warn(`GSplatAssetLoader: Retrying load for ${url} (attempt ${retryCount + 1}/${this.maxRetries})`);
this._registry.load(asset);
} else {
// Max retries exceeded
Debug.error(`GSplatAssetLoader: Failed to load ${url} after ${this.maxRetries} retries: ${err}`);
// Remove from currently loading
this._currentlyLoading.delete(url);
// Clear retry count
this._retryCount.delete(url);
// Process next item in queue
this._processQueue();
}
}
/**
* Processes the next item in the load queue if there's capacity.
*
* @private
*/ _processQueue() {
// Don't process queue if destroyed or handler removed
if (this._destroyed || !this._canLoad()) {
return;
}
while(this._currentlyLoading.size < this.maxConcurrentLoads && this._loadQueue.length > 0){
const url = this._loadQueue.shift();
if (url) {
this._startLoading(url);
}
}
}
/**
* Unloads an asset that was previously loaded by this loader. The asset resource will be
* destroyed and freed from memory.
*
* @param {string} url - The URL of the asset to unload.
*/ unload(url) {
// Remove from loading state
this._currentlyLoading.delete(url);
// Remove from queue if present
const queueIndex = this._loadQueue.indexOf(url);
if (queueIndex !== -1) {
this._loadQueue.splice(queueIndex, 1);
}
// Clear retry count
this._retryCount.delete(url);
// Unload the asset
const asset = this._urlToAsset.get(url);
if (asset) {
// IMPORTANT: Fire 'unload' event explicitly before calling asset.unload()
// This ensures parsers with async loading (like sog.js) can clean up
// even if the asset hasn't finished loading yet
// NOTE: Must fire BEFORE removing event listeners
asset.fire('unload', asset);
// Remove event listeners
asset.off('load');
asset.off('error');
this._registry.remove(asset);
asset.unload();
this._urlToAsset.delete(url);
}
// Process queue in case we freed up a slot
this._processQueue();
}
/**
* Gets the resource for a given URL if it has been loaded by this loader.
* Use this when you just need the loaded resource data.
*
* @param {string} url - The URL of the asset to retrieve the resource from.
* @returns {object|undefined} The loaded resource if found and loaded, undefined otherwise.
*/ getResource(url) {
const asset = this._urlToAsset.get(url);
return asset?.resource;
}
/**
* Create a new GSplatAssetLoader.
*
* @param {AssetRegistry} registry - The asset registry to use for loading assets.
*/ constructor(registry){
super(), /**
* Map of URL to Asset instances that this loader has created.
*
* @type {Map<string, Asset>}
* @private
*/ this._urlToAsset = new Map(), /**
* Maximum number of assets that can be loading concurrently.
*
* @type {number}
* @private
*/ this.maxConcurrentLoads = 2, /**
* Maximum number of retry attempts for failed loads.
*
* @type {number}
* @private
*/ this.maxRetries = 2, /**
* Set of URLs currently being loaded.
*
* @type {Set<string>}
* @private
*/ this._currentlyLoading = new Set(), /**
* Queue of URLs waiting to be loaded.
*
* @type {string[]}
* @private
*/ this._loadQueue = [], /**
* Map tracking retry attempts per URL.
*
* @type {Map<string, number>}
* @private
*/ this._retryCount = new Map(), /**
* Whether this asset loader has been destroyed.
*
* @type {boolean}
* @private
*/ this._destroyed = false;
this._registry = registry;
}
}
export { GSplatAssetLoader };