3d-tiles-renderer
Version:
https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification
1,012 lines (642 loc) • 22.1 kB
JavaScript
import { getUrlExtension } from '../utilities/urlExtension.js';
import { LRUCache } from '../utilities/LRUCache.js';
import { PriorityQueue } from '../utilities/PriorityQueue.js';
import { markUsedTiles, toggleTiles, markVisibleTiles, markUsedSetLeaves, traverseSet } from './traverseFunctions.js';
import { UNLOADED, LOADING, PARSING, LOADED, FAILED } from './constants.js';
import { throttle } from '../utilities/throttle.js';
const PLUGIN_REGISTERED = Symbol( 'PLUGIN_REGISTERED' );
// priority queue sort function that takes two tiles to compare. Returning 1 means
// "tile a" is loaded first.
const priorityCallback = ( a, b ) => {
if ( a.__depthFromRenderedParent !== b.__depthFromRenderedParent ) {
// load shallower tiles first using "depth from rendered parent" to help
// even out depth disparities caused by non-content parent tiles
return a.__depthFromRenderedParent > b.__depthFromRenderedParent ? - 1 : 1;
} else if ( a.__inFrustum !== b.__inFrustum ) {
// load tiles that are in the frustum at the current depth
return a.__inFrustum ? 1 : - 1;
} else if ( a.__used !== b.__used ) {
// load tiles that have been used
return a.__used ? 1 : - 1;
} else if ( a.__error !== b.__error ) {
// load the tile with the higher error
return a.__error > b.__error ? 1 : - 1;
} else if ( a.__distanceFromCamera !== b.__distanceFromCamera ) {
// and finally visible tiles which have equal error (ex: if geometricError === 0)
// should prioritize based on distance.
return a.__distanceFromCamera > b.__distanceFromCamera ? - 1 : 1;
}
return 0;
};
// lru cache unload callback that takes two tiles to compare. Returning 1 means "tile a"
// is unloaded first.
const lruPriorityCallback = ( a, b ) => {
if ( a.__depthFromRenderedParent !== b.__depthFromRenderedParent ) {
// dispose of deeper tiles first
return a.__depthFromRenderedParent > b.__depthFromRenderedParent ? 1 : - 1;
} else if ( a.__loadingState !== b.__loadingState ) {
// dispose of tiles that are earlier along in the loading process first
return a.__loadingState > b.__loadingState ? - 1 : 1;
} else if ( a.__lastFrameVisited !== b.__lastFrameVisited ) {
// dispose of least recent tiles first
return a.__lastFrameVisited > b.__lastFrameVisited ? - 1 : 1;
} else if ( a.__hasUnrenderableContent !== b.__hasUnrenderableContent ) {
// dispose of external tile sets last
return a.__hasUnrenderableContent ? - 1 : 1;
} else if ( a.__error !== b.__error ) {
// unload the tile with lower error
return a.__error > b.__error ? - 1 : 1;
}
return 0;
};
export class TilesRendererBase {
get root() {
const tileSet = this.rootTileSet;
return tileSet ? tileSet.root : null;
}
get loadProgress() {
const { stats, isLoading } = this;
const loading = stats.downloading + stats.parsing;
const total = stats.inCacheSinceLoad + ( isLoading ? 1 : 0 );
return total === 0 ? 1.0 : 1.0 - loading / total;
}
get errorThreshold() {
return this._errorThreshold;
}
set errorThreshold( v ) {
console.warn( 'TilesRenderer: The "errorThreshold" option has been deprecated.' );
this._errorThreshold = v;
}
constructor( url = null ) {
// state
this.rootLoadingState = UNLOADED;
this.rootTileSet = null;
this.rootURL = url;
this.fetchOptions = {};
this.plugins = [];
this.queuedTiles = [];
this.cachedSinceLoadComplete = new Set();
this.isLoading = false;
const lruCache = new LRUCache();
lruCache.unloadPriorityCallback = lruPriorityCallback;
const downloadQueue = new PriorityQueue();
downloadQueue.maxJobs = 10;
downloadQueue.priorityCallback = priorityCallback;
const parseQueue = new PriorityQueue();
parseQueue.maxJobs = 1;
parseQueue.priorityCallback = priorityCallback;
const processNodeQueue = new PriorityQueue();
processNodeQueue.maxJobs = 25;
processNodeQueue.priorityCallback = priorityCallback;
processNodeQueue.log = true;
this.visibleTiles = new Set();
this.activeTiles = new Set();
this.usedSet = new Set();
this.lruCache = lruCache;
this.downloadQueue = downloadQueue;
this.parseQueue = parseQueue;
this.processNodeQueue = processNodeQueue;
this.stats = {
inCacheSinceLoad: 0,
inCache: 0,
parsing: 0,
downloading: 0,
failed: 0,
inFrustum: 0,
used: 0,
active: 0,
visible: 0,
};
this.frameCount = 0;
// callbacks
this._dispatchNeedsUpdateEvent = throttle( () => {
this.dispatchEvent( { type: 'needs-update' } );
} );
// options
this.errorTarget = 16.0;
this._errorThreshold = Infinity;
this.displayActiveTiles = false;
this.maxDepth = Infinity;
}
// Plugins
registerPlugin( plugin ) {
if ( plugin[ PLUGIN_REGISTERED ] === true ) {
throw new Error( 'TilesRendererBase: A plugin can only be registered to a single tile set' );
}
// insert the plugin based on the priority registered on the plugin
const plugins = this.plugins;
const priority = plugin.priority || 0;
let insertionPoint = plugins.length;
for ( let i = 0; i < plugins.length; i ++ ) {
const otherPriority = plugins[ i ].priority || 0;
if ( otherPriority > priority ) {
insertionPoint = i;
break;
}
}
plugins.splice( insertionPoint, 0, plugin );
plugin[ PLUGIN_REGISTERED ] = true;
if ( plugin.init ) {
plugin.init( this );
}
}
unregisterPlugin( plugin ) {
const plugins = this.plugins;
if ( typeof plugin === 'string' ) {
plugin = this.getPluginByName( name );
}
if ( plugins.includes( plugin ) ) {
const index = plugins.indexOf( plugin );
plugins.splice( index, 1 );
if ( plugin.dispose ) {
plugin.dispose();
}
return true;
}
return false;
}
getPluginByName( name ) {
return this.plugins.find( p => p.name === name ) || null;
}
traverse( beforecb, aftercb, ensureFullyProcessed = true ) {
if ( ! this.root ) return;
traverseSet( this.root, ( tile, ...args ) => {
if ( ensureFullyProcessed ) {
this.ensureChildrenArePreprocessed( tile, true );
}
return beforecb ? beforecb( tile, ...args ) : false;
}, aftercb );
}
queueTileForDownload( tile ) {
if ( tile.__loadingState !== UNLOADED ) {
return;
}
this.queuedTiles.push( tile );
}
markTileUsed( tile ) {
// save the tile in a separate "used set" so we can mark it as unused
// before the next tile set traversal
this.usedSet.add( tile );
this.lruCache.markUsed( tile );
}
// Public API
update() {
const { lruCache, usedSet, stats, root, downloadQueue, parseQueue, processNodeQueue } = this;
if ( this.rootLoadingState === UNLOADED ) {
this.rootLoadingState = LOADING;
this.invokeOnePlugin( plugin => plugin.loadRootTileSet && plugin.loadRootTileSet() )
.then( root => {
let processedUrl = this.rootURL;
if ( processedUrl !== null ) {
this.invokeAllPlugins( plugin => processedUrl = plugin.preprocessURL ? plugin.preprocessURL( processedUrl, null ) : processedUrl );
}
this.rootLoadingState = LOADED;
this.rootTileSet = root;
this.dispatchEvent( { type: 'needs-update' } );
this.dispatchEvent( { type: 'load-content' } );
this.dispatchEvent( {
type: 'load-tile-set',
tileSet: root,
url: processedUrl,
} );
} )
.catch( error => {
this.rootLoadingState = FAILED;
console.error( error );
this.rootTileSet = null;
this.dispatchEvent( {
type: 'load-error',
tile: null,
error,
url: this.rootURL,
} );
} );
}
if ( ! root ) {
return;
}
stats.inFrustum = 0;
stats.used = 0;
stats.active = 0;
stats.visible = 0;
this.frameCount ++;
usedSet.forEach( tile => lruCache.markUnused( tile ) );
usedSet.clear();
markUsedTiles( root, this );
markUsedSetLeaves( root, this );
markVisibleTiles( root, this );
toggleTiles( root, this );
// TODO: This will only sort for one tile set. We may want to store this queue on the
// LRUCache so multiple tile sets can use it at once
// start the downloads of the tiles as needed
const queuedTiles = this.queuedTiles;
queuedTiles.sort( lruCache.unloadPriorityCallback );
for ( let i = 0, l = queuedTiles.length; i < l && ! lruCache.isFull(); i ++ ) {
this.requestTileContents( queuedTiles[ i ] );
}
queuedTiles.length = 0;
// start the downloads
lruCache.scheduleUnload();
// if all tasks have finished and we've been marked as actively loading then fire the completion event
const runningTasks = downloadQueue.running || parseQueue.running || processNodeQueue.running;
if ( runningTasks === false && this.isLoading === true ) {
this.cachedSinceLoadComplete.clear();
stats.inCacheSinceLoad = 0;
this.dispatchEvent( { type: 'tiles-load-end' } );
this.isLoading = false;
}
}
resetFailedTiles() {
// reset the root tile if it's finished but never loaded
if ( this.rootLoadingState === FAILED ) {
this.rootLoadingState = UNLOADED;
}
const stats = this.stats;
if ( stats.failed === 0 ) {
return;
}
this.traverse( tile => {
if ( tile.__loadingState === FAILED ) {
tile.__loadingState = UNLOADED;
}
}, null, false );
stats.failed = 0;
}
dispose() {
// dispose of all the plugins
this.plugins.forEach( plugin => {
this.unregisterPlugin( plugin );
} );
const lruCache = this.lruCache;
// Make sure we've collected all children before disposing of the internal tilesets to avoid
// dangling children that we inadvertantly skip when deleting the nested tileset.
const toRemove = [];
this.traverse( t => {
toRemove.push( t );
return false;
}, null, false );
for ( let i = 0, l = toRemove.length; i < l; i ++ ) {
lruCache.remove( toRemove[ i ] );
}
this.stats = {
parsing: 0,
downloading: 0,
failed: 0,
inFrustum: 0,
used: 0,
active: 0,
visible: 0,
};
this.frameCount = 0;
}
// Overrideable
dispatchEvent( e ) {
// event to be overriden for dispatching via an event system
}
fetchData( url, options ) {
return fetch( url, options );
}
parseTile( buffer, tile, extension ) {
return null;
}
disposeTile( tile ) {
// TODO: are these necessary? Are we disposing tiles when they are currently visible?
if ( tile.__visible ) {
this.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, false ) );
tile.__visible = false;
}
if ( tile.__active ) {
this.invokeOnePlugin( plugin => plugin.setTileActive && plugin.setTileActive( tile, false ) );
tile.__active = false;
}
}
preprocessNode( tile, tileSetDir, parentTile = null ) {
if ( tile.content ) {
// Fix old file formats
if ( ! ( 'uri' in tile.content ) && 'url' in tile.content ) {
tile.content.uri = tile.content.url;
delete tile.content.url;
}
// NOTE: fix for some cases where tilesets provide the bounding volume
// but volumes are not present.
if (
tile.content.boundingVolume &&
! (
'box' in tile.content.boundingVolume ||
'sphere' in tile.content.boundingVolume ||
'region' in tile.content.boundingVolume
)
) {
delete tile.content.boundingVolume;
}
}
tile.parent = parentTile;
tile.children = tile.children || [];
if ( tile.content?.uri ) {
// "content" should only indicate loadable meshes, not external tile sets
const extension = getUrlExtension( tile.content.uri );
tile.__hasContent = true;
tile.__hasUnrenderableContent = Boolean( extension && /json$/.test( extension ) );
tile.__hasRenderableContent = ! tile.__hasUnrenderableContent;
} else {
tile.__hasContent = false;
tile.__hasUnrenderableContent = false;
tile.__hasRenderableContent = false;
}
// tracker for determining if all the children have been asynchronously
// processed and are ready to be traversed
tile.__childrenProcessed = 0;
if ( parentTile ) {
parentTile.__childrenProcessed ++;
}
tile.__distanceFromCamera = Infinity;
tile.__error = Infinity;
tile.__inFrustum = false;
tile.__isLeaf = false;
tile.__usedLastFrame = false;
tile.__used = false;
tile.__wasSetVisible = false;
tile.__visible = false;
tile.__childrenWereVisible = false;
tile.__allChildrenLoaded = false;
tile.__wasSetActive = false;
tile.__active = false;
tile.__loadingState = UNLOADED;
if ( parentTile === null ) {
tile.__depth = 0;
tile.__depthFromRenderedParent = ( tile.__hasRenderableContent ? 1 : 0 );
tile.refine = tile.refine || 'REPLACE';
} else {
// increment the "depth from parent" when we encounter a new tile with content
tile.__depth = parentTile.__depth + 1;
tile.__depthFromRenderedParent = parentTile.__depthFromRenderedParent + ( tile.__hasRenderableContent ? 1 : 0 );
tile.refine = tile.refine || parentTile.refine;
}
tile.__basePath = tileSetDir;
tile.__lastFrameVisited = - 1;
this.invokeAllPlugins( plugin => {
plugin !== this && plugin.preprocessNode && plugin.preprocessNode( tile, tileSetDir, parentTile );
} );
}
setTileActive( tile, active ) {
active ? this.activeTiles.add( tile ) : this.activeTiles.delete( tile );
}
setTileVisible( tile, visible ) {
visible ? this.visibleTiles.add( tile ) : this.visibleTiles.delete( tile );
}
calculateTileViewError( tile, target ) {
// retrieve whether the tile is visible, screen space error, and distance to camera
// set "inView", "error", "distance"
}
ensureChildrenArePreprocessed( tile, immediate = false ) {
const children = tile.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const child = children[ i ];
if ( '__depth' in child ) {
// the child has already been processed
break;
} else if ( immediate ) {
// process the node immediately and make sure we don't double process it
this.processNodeQueue.remove( child );
this.preprocessNode( child, tile.__basePath, tile );
} else {
// queue the node for processing if it hasn't been already
if ( ! this.processNodeQueue.has( child ) ) {
this.processNodeQueue.add( child, child => {
this.preprocessNode( child, tile.__basePath, tile );
this._dispatchNeedsUpdateEvent();
} );
}
}
}
}
// Private Functions
preprocessTileSet( json, url, parent = null ) {
const version = json.asset.version;
const [ major, minor ] = version.split( '.' ).map( v => parseInt( v ) );
console.assert(
major <= 1,
'TilesRenderer: asset.version is expected to be a 1.x or a compatible version.',
);
if ( major === 1 && minor > 0 ) {
console.warn( 'TilesRenderer: tiles versions at 1.1 or higher have limited support. Some new extensions and features may not be supported.' );
}
// remove the last file path path-segment from the URL including the trailing slash
let basePath = url.replace( /\/[^/]*$/, '' );
basePath = new URL( basePath, window.location.href ).toString();
this.preprocessNode( json.root, basePath, parent );
}
loadRootTileSet() {
// transform the url
let processedUrl = this.rootURL;
this.invokeAllPlugins( plugin => processedUrl = plugin.preprocessURL ? plugin.preprocessURL( processedUrl, null ) : processedUrl );
// load the tile set root
const pr = this
.invokeOnePlugin( plugin => plugin.fetchData && plugin.fetchData( processedUrl, this.fetchOptions ) )
.then( res => {
if ( res.ok ) {
return res.json();
} else {
throw new Error( `TilesRenderer: Failed to load tileset "${ processedUrl }" with status ${ res.status } : ${ res.statusText }` );
}
} )
.then( root => {
this.preprocessTileSet( root, processedUrl );
return root;
} );
return pr;
}
requestTileContents( tile ) {
// If the tile is already being loaded then don't
// start it again.
if ( tile.__loadingState !== UNLOADED ) {
return;
}
let isExternalTileSet = false;
let externalTileset = null;
let uri = new URL( tile.content.uri, tile.__basePath + '/' ).toString();
this.invokeAllPlugins( plugin => uri = plugin.preprocessURL ? plugin.preprocessURL( uri, tile ) : uri );
const stats = this.stats;
const lruCache = this.lruCache;
const downloadQueue = this.downloadQueue;
const parseQueue = this.parseQueue;
const extension = getUrlExtension( uri );
// track an abort controller and pass-through the below conditions if aborted
const controller = new AbortController();
const signal = controller.signal;
const addedSuccessfully = lruCache.add( tile, t => {
// Stop the load if it's started
controller.abort();
// Clear out all tile content
if ( isExternalTileSet ) {
t.children.length = 0;
t.__childrenProcessed = 0;
} else {
this.invokeAllPlugins( plugin => {
plugin.disposeTile && plugin.disposeTile( t );
} );
}
// Decrement stats
stats.inCache --;
if ( this.cachedSinceLoadComplete.has( tile ) ) {
this.cachedSinceLoadComplete.delete( tile );
stats.inCacheSinceLoad --;
}
if ( t.__loadingState === LOADING ) {
stats.downloading --;
} else if ( t.__loadingState === PARSING ) {
stats.parsing --;
}
t.__loadingState = UNLOADED;
parseQueue.remove( t );
downloadQueue.remove( t );
} );
// if we couldn't add the tile to the lru cache because it's full then skip
if ( ! addedSuccessfully ) {
return;
}
// check if this is the beginning of a new set of tiles to load and dispatch and event
if ( ! this.isLoading ) {
this.isLoading = true;
this.dispatchEvent( { type: 'tiles-load-start' } );
}
this.cachedSinceLoadComplete.add( tile );
stats.inCacheSinceLoad ++;
stats.inCache ++;
stats.downloading ++;
tile.__loadingState = LOADING;
// queue the download and parse
return downloadQueue.add( tile, downloadTile => {
if ( signal.aborted ) {
return Promise.resolve();
}
return this.invokeOnePlugin( plugin => plugin.fetchData && plugin.fetchData( uri, { ...this.fetchOptions, signal } ) );
} )
.then( res => {
if ( signal.aborted ) {
return;
}
if ( res.ok ) {
return extension === 'json' ? res.json() : res.arrayBuffer();
} else {
throw new Error( `Failed to load model with error code ${res.status}` );
}
} )
.then( content => {
// if it has been unloaded then the tile has been disposed
if ( signal.aborted ) {
return;
}
stats.downloading --;
stats.parsing ++;
tile.__loadingState = PARSING;
return parseQueue.add( tile, parseTile => {
// if it has been unloaded then the tile has been disposed
if ( signal.aborted ) {
return Promise.resolve();
}
if ( extension === 'json' && content.root ) {
this.preprocessTileSet( content, uri, tile );
tile.children.push( content.root );
externalTileset = content;
isExternalTileSet = true;
return Promise.resolve();
} else {
return this.invokeOnePlugin( plugin => plugin.parseTile && plugin.parseTile( content, parseTile, extension, uri, signal ) );
}
} );
} )
.then( () => {
// if it has been unloaded then the tile has been disposed
if ( signal.aborted ) {
return;
}
stats.parsing --;
tile.__loadingState = LOADED;
lruCache.setLoaded( tile, true );
// If the memory of the item hasn't been registered yet then that means the memory usage hasn't
// been accounted for by the cache yet so we need to check if it fits or if we should remove it.
if ( lruCache.getMemoryUsage( tile ) === null ) {
if ( lruCache.isFull() && lruCache.computeMemoryUsageCallback( tile ) > 0 ) {
// And if the cache is full due to newly loaded memory then lets discard this tile - it will
// be loaded again later from the disk cache if needed.
lruCache.remove( tile );
} else {
// Otherwise update the item to the latest known value
lruCache.updateMemoryUsage( tile );
}
}
// dispatch an event indicating that this model has completed and that a new
// call to "update" is needed.
this.dispatchEvent( { type: 'needs-update' } );
this.dispatchEvent( { type: 'load-content' } );
if ( isExternalTileSet ) {
this.dispatchEvent( {
type: 'load-tile-set',
tileSet: externalTileset,
url: uri,
} );
}
if ( tile.cached.scene ) {
this.dispatchEvent( {
type: 'load-model',
scene: tile.cached.scene,
tile,
} );
}
} )
.catch( error => {
// if it has been unloaded then the tile has been disposed
if ( signal.aborted ) {
return;
}
if ( error.name !== 'AbortError' ) {
parseQueue.remove( tile );
downloadQueue.remove( tile );
if ( tile.__loadingState === PARSING ) {
stats.parsing --;
} else if ( tile.__loadingState === LOADING ) {
stats.downloading --;
}
stats.failed ++;
console.error( `TilesRenderer : Failed to load tile at url "${ tile.content.uri }".` );
console.error( error );
tile.__loadingState = FAILED;
lruCache.setLoaded( tile, true );
this.dispatchEvent( {
type: 'load-error',
tile,
error,
url: uri,
} );
} else {
lruCache.remove( tile );
}
} );
}
getAttributions( target = [] ) {
this.invokeAllPlugins( plugin => plugin !== this && plugin.getAttributions && plugin.getAttributions( target ) );
return target;
}
invokeOnePlugin( func ) {
const plugins = [ ...this.plugins, this ];
for ( let i = 0; i < plugins.length; i ++ ) {
const result = func( plugins[ i ] );
if ( result ) {
return result;
}
}
return null;
}
invokeAllPlugins( func ) {
const plugins = [ ...this.plugins, this ];
const pending = [];
for ( let i = 0; i < plugins.length; i ++ ) {
const result = func( plugins[ i ] );
if ( result ) {
pending.push( result );
}
}
return pending.length === 0 ? null : Promise.all( pending );
}
}