UNPKG

3d-tiles-renderer

Version:

https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification

1,785 lines (1,229 loc) 45.3 kB
import { getUrlExtension } from '../utilities/urlExtension.js'; import { LRUCache } from '../utilities/LRUCache.js'; import { PriorityQueue } from '../utilities/PriorityQueue.js'; import { runTraversal } from './traverseFunctions.js'; import { UNLOADED, QUEUED, LOADING, PARSING, LOADED, FAILED } from '../constants.js'; import { throttle } from '../utilities/throttle.js'; import { traverseSet } from '../utilities/TraversalUtils.js'; /** * @callback TileBeforeCallback * @param {Tile} tile * @param {Tile|null} parent * @param {number} depth * @returns {boolean} */ /** * @callback TileAfterCallback * @param {Tile} tile * @param {Tile|null} parent * @param {number} depth */ /** * @callback EventCallback * @param {Object} event */ const PLUGIN_REGISTERED = Symbol( 'PLUGIN_REGISTERED' ); const regionErrorTarget = { inView: true, error: 0, distance: Infinity, }; // Priority callbacks for the download and parse queues. Returning 1 means "tile a" is loaded first. // Differences between the two: // errorPriorityCallback: sorts by error (high → low), then distance. Used when loadAncestors is enabled. // distancePriorityCallback: sorts by inFrustum, hasUnrenderableContent, then distance. No error sort. const errorPriorityCallback = ( a, b ) => { const aPriority = a.priority || 0; const bPriority = b.priority || 0; if ( aPriority !== bPriority ) { // lower priority value sorts first return aPriority > bPriority ? 1 : - 1; } else if ( ! a.traversal || ! b.traversal ) { return 0; } else if ( a.traversal.used !== b.traversal.used ) { // load tiles that have been used return a.traversal.used ? 1 : - 1; } else if ( a.traversal.error !== b.traversal.error ) { // load the tile with the higher error return a.traversal.error > b.traversal.error ? 1 : - 1; } else if ( a.traversal.distanceFromCamera !== b.traversal.distanceFromCamera ) { // and finally visible tiles which have equal error (ex: if geometricError === 0) // should prioritize based on distance. return a.traversal.distanceFromCamera > b.traversal.distanceFromCamera ? - 1 : 1; } else if ( a.internal.depthFromRenderedParent !== b.internal.depthFromRenderedParent ) { return a.internal.depthFromRenderedParent > b.internal.depthFromRenderedParent ? - 1 : 1; } return 0; }; const distancePriorityCallback = ( a, b ) => { if ( a.traversal.used !== b.traversal.used ) { // load tiles that have been used return a.traversal.used ? 1 : - 1; } else if ( a.traversal.inFrustum !== b.traversal.inFrustum ) { // load tiles that have are in the frustum return a.traversal.inFrustum ? 1 : - 1; } else if ( a.internal.hasUnrenderableContent !== b.internal.hasUnrenderableContent ) { // load internal tile sets first return a.internal.hasUnrenderableContent ? 1 : - 1; } else if ( a.traversal.distanceFromCamera !== b.traversal.distanceFromCamera ) { // load closer tiles first return a.traversal.distanceFromCamera > b.traversal.distanceFromCamera ? - 1 : 1; } else if ( a.internal.depthFromRenderedParent !== b.internal.depthFromRenderedParent ) { // when distance is equal (e.g. camera inside bounds), load shallower tiles first return a.internal.depthFromRenderedParent > b.internal.depthFromRenderedParent ? - 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.traversal.lastFrameVisited !== b.traversal.lastFrameVisited ) { // dispose of least recent tiles first return a.traversal.lastFrameVisited > b.traversal.lastFrameVisited ? - 1 : 1; } else if ( a.internal.depthFromRenderedParent !== b.internal.depthFromRenderedParent ) { // dispose of deeper tiles first so parents are not disposed before children return a.internal.depthFromRenderedParent > b.internal.depthFromRenderedParent ? 1 : - 1; } else if ( a.internal.loadingState !== b.internal.loadingState ) { // dispose of tiles that are earlier along in the loading process first return a.internal.loadingState > b.internal.loadingState ? - 1 : 1; } else if ( a.internal.hasUnrenderableContent !== b.internal.hasUnrenderableContent ) { // dispose of external tilesets last return a.internal.hasUnrenderableContent ? - 1 : 1; } else if ( a.traversal.error !== b.traversal.error ) { // unload the tile with lower error return a.traversal.error > b.traversal.error ? - 1 : 1; } return 0; }; // Unified priority callback for shared queues — delegates to the appropriate per-tile callback // based on each tile's renderer settings. Falls back to errorPriorityCallback for cross-renderer // comparisons or when renderer settings differ. export const unifiedPriorityCallback = ( a, b ) => { // handle non-tile cases const aPriority = a.priority ?? Infinity; const bPriority = b.priority ?? Infinity; if ( aPriority !== bPriority ) { // lower priority value sorts first return aPriority > bPriority ? 1 : - 1; } else if ( ! a.internal || ! b.internal ) { return 0; } const aRenderer = a.internal.renderer; const bRenderer = b.internal.renderer; const aOptimized = ! aRenderer.loadAncestors; const bOptimized = ! bRenderer.loadAncestors; if ( aOptimized && bOptimized ) { return distancePriorityCallback( a, b ); } else { return errorPriorityCallback( a, b ); } }; // Internal Tile Type Definitions /** * Internal renderer state added to each tile during preprocessing. * @typedef {Object} TileInternalData * @property {boolean} hasContent - Whether the tile has a content URI. * @property {boolean} hasRenderableContent - Whether the tile content is a renderable model (not an external tileset). * @property {boolean} hasUnrenderableContent - Whether the tile content is an external tileset JSON. * @property {number} loadingState - Current loading state constant (UNLOADED, QUEUED, LOADING, PARSING, LOADED, or FAILED). * @property {string} basePath - Base URL used to resolve relative content URIs. * @property {number} depth - Depth of this tile in the full tile hierarchy. * @property {number} depthFromRenderedParent - Depth from the nearest ancestor with renderable content. * @property {boolean} isVirtual - Whether this tile was synthetically generated by a plugin. * @property {number} virtualChildCount - Number of virtual children appended to this tile by plugins. * @property {TilesRendererBase} renderer - The renderer instance that owns this tile. */ /** * Per-frame traversal state updated on each tile during `TilesRendererBase.update`. * @typedef {Object} TileTraversalData * @property {number} distanceFromCamera - Distance from the tile bounds to the nearest active camera. * @property {number} error - Screen space error computed for this tile. * @property {boolean} inFrustum - Whether the tile was within the camera frustum on the last update. * @property {boolean} isLeaf - Whether this tile is a leaf node in the used tile tree. * @property {boolean} used - Whether this tile was visited during the last update traversal. * @property {boolean} usedLastFrame - Whether this tile was visited in the previous frame. * @property {boolean} visible - Whether this tile is currently visible (loaded, in frustum, meets SSE). */ /** * A 3D Tiles tile with both spec fields (from tileset JSON) and renderer-managed state. * @typedef {Object} Tile * @property {Object} boundingVolume - Bounding volume. Has either a `box` (12-element array) or `sphere` (4-element array) field. * @property {number} geometricError - Error in meters introduced if this tile is not rendered. * @property {Tile|null} parent - Parent tile, or null for the root. * @property {Tile[]} [children] - Child tiles. * @property {Object} [content] - Loadable content URI reference. * @property {'REPLACE'|'ADD'} [refine] - Refinement strategy; inherited from the parent if omitted. * @property {number[]} [transform] - Optional 4x4 column-major transform matrix. * @property {Object} [extensions] - Extension-specific objects. * @property {Object} [extras] - Extra application-specific data. * @property {TileInternalData} internal - Internal renderer state. * @property {TileTraversalData} traversal - Per-frame traversal state. */ /** * A loaded 3D Tiles tileset JSON object. * @typedef {Object} Tileset * @property {Object} asset - Metadata about the tileset. Contains `version` (string) and optional `tilesetVersion` (string). * @property {number} geometricError - Error in meters for the entire tileset. * @property {Tile} root - The root tile. * @property {string[]} [extensionsUsed] - Names of extensions used somewhere in the tileset. * @property {string[]} [extensionsRequired] - Names of extensions required to load the tileset. * @property {Object} [properties] - Metadata about per-feature properties. * @property {Object} [extensions] - Extension-specific objects. * @property {Object} [extras] - Extra application-specific data. */ // // TilesRendererBase Event Definitions /** * Fired when the renderer determines a new render is required — e.g. after a tile loads. * @event TilesRendererBase#needs-update */ /** * Fired when any tileset JSON finishes loading. * @event TilesRendererBase#load-tileset * @property {Tileset} tileset - The loaded tileset object. * @property {string} url - The URL from which the tileset was loaded. */ /** * Fired when the root tileset JSON finishes loading. * @event TilesRendererBase#load-root-tileset * @property {Tileset} tileset - The loaded root tileset object. * @property {string} url - The URL from which the tileset was loaded. */ /** * Fired when tile downloads begin after a period of inactivity. * @event TilesRendererBase#tiles-load-start */ /** * Fired when all pending tile downloads and parses have completed. * @event TilesRendererBase#tiles-load-end */ /** * Fired when a tile content download begins. * @event TilesRendererBase#tile-download-start * @property {Tile} tile - The tile being downloaded. * @property {string} url - The URL being fetched. */ /** * Fired when a tile's renderable content (model/scene) is created. * The `scene` type is engine-specific (e.g. `THREE.Group` in three.js). * @event TilesRendererBase#load-model * @property {Object} scene - The engine-specific scene object created for this tile. * @property {Tile} tile - The tile the scene belongs to. * @property {string} url - The URL the content was loaded from. */ /** * Fired when a tile's renderable content is about to be removed and destroyed. * The `scene` type is engine-specific (e.g. `THREE.Group` in three.js). * @event TilesRendererBase#dispose-model * @property {Object} scene - The engine-specific scene object being disposed. * @property {Tile} tile - The tile the scene belonged to. */ /** * Fired when a tile transitions between visible and hidden. * The `scene` type is engine-specific (e.g. `THREE.Group` in three.js). * @event TilesRendererBase#tile-visibility-change * @property {Object} scene - The engine-specific scene object. * @property {Tile} tile - The tile whose visibility changed. * @property {boolean} visible - Whether the tile is now visible. */ /** * Fired at the start of each `update()` call, before traversal begins. * @event TilesRendererBase#update-before */ /** * Fired at the end of each `update()` call, after traversal completes. * @event TilesRendererBase#update-after */ /** * Fired when a tile or tileset fails to load. * @event TilesRendererBase#load-error * @property {Tile|null} tile - The tile that failed, or null if a root tileset failed. * @property {Error} error - The error that occurred. * @property {string|URL} url - The URL that failed to load. */ // Default shared caches and queues const DEFAULT_LRU_CACHE = new LRUCache(); DEFAULT_LRU_CACHE.unloadPriorityCallback = lruPriorityCallback; export const DEFAULT_DOWNLOAD_QUEUE = new PriorityQueue(); DEFAULT_DOWNLOAD_QUEUE.maxJobs = 25; DEFAULT_DOWNLOAD_QUEUE.priorityCallback = unifiedPriorityCallback; const DEFAULT_PARSE_QUEUE = new PriorityQueue(); DEFAULT_PARSE_QUEUE.maxJobs = 5; DEFAULT_PARSE_QUEUE.priorityCallback = unifiedPriorityCallback; const DEFAULT_NODE_QUEUE = new PriorityQueue(); DEFAULT_NODE_QUEUE.maxJobs = 25; DEFAULT_NODE_QUEUE.priorityCallback = ( a, b ) => { const aParent = a.parent; const bParent = b.parent; if ( aParent === bParent ) { return 0; } else if ( ! aParent ) { return 1; } else if ( ! bParent ) { return - 1; } else { // fall back to the priority used for tile loads and parsing return unifiedPriorityCallback( aParent, bParent ); } }; /** * Base class for 3D Tiles renderers. Manages tile loading, caching, traversal, * and a plugin system for extending rendering behavior. Engine-specific renderers * extend this class to add camera projection, scene management, and tile display. */ export class TilesRendererBase { /** * Root tile of the loaded root tileset, or null if not yet loaded. * @type {Tile|null} * @readonly */ get root() { const tileset = this.rootTileset; return tileset ? tileset.root : null; } /** * Fraction of tiles loaded since the last idle state, from 0 (nothing loaded) to 1 (all loaded). * @type {number} * @readonly */ get loadProgress() { const { stats, isLoading } = this; const loading = stats.queued + stats.downloading + stats.parsing; const total = stats.inCacheSinceLoad + ( isLoading ? 1 : 0 ); return total === 0 ? 1.0 : 1.0 - loading / total; } /** * @param {string} [url] - URL of the root tileset JSON to load. */ constructor( url = null ) { // state this.rootLoadingState = UNLOADED; /** * The loaded root tileset object, or null if not yet loaded. * @type {Tileset|null} * @readonly */ this.rootTileset = null; this.rootURL = url; /** * Options passed to `fetch` when loading tile and tileset resources. * @type {RequestInit} * @default {} */ this.fetchOptions = {}; this.plugins = []; this.queuedTiles = []; this.cachedSinceLoadComplete = new Set(); this.isLoading = false; this.processedTiles = new WeakSet(); /** * Set of all tiles that are currently visible. * @type {Set<Tile>} * @readonly */ this.visibleTiles = new Set(); /** * Set of all tiles that are currently active (displayed as a stand-in while children load). * @type {Set<Tile>} * @readonly */ this.activeTiles = new Set(); this.usedSet = new Set(); this.loadingTiles = new Set(); /** * LRU cache managing loaded tile lifecycle and memory eviction. * @note Cannot be replaced once `update()` has been called for the first time. * @type {LRUCache} */ this.lruCache = DEFAULT_LRU_CACHE; /** * Priority queue controlling concurrent tile downloads. Max jobs defaults to `25`. * @note Cannot be replaced once `update()` has been called for the first time. * @type {PriorityQueue} */ this.downloadQueue = DEFAULT_DOWNLOAD_QUEUE; /** * Priority queue controlling concurrent tile parsing. Max jobs defaults to `5`. * @note Cannot be modified once `update()` has been called for the first time. * @type {PriorityQueue} */ this.parseQueue = DEFAULT_PARSE_QUEUE; /** * Priority queue for expanding and initializing tiles for traversal. Max jobs defaults to `25`. * @note Cannot be replaced once `update()` has been called for the first time. * @type {PriorityQueue} */ this.processNodeQueue = DEFAULT_NODE_QUEUE; /** * Loading and rendering statistics updated each frame. Fields: * - `inCache` — tiles currently in the LRU cache * - `queued` — tiles queued for download * - `downloading` — tiles currently downloading * - `parsing` — tiles currently being parsed * - `loaded` — tiles that have finished loading * - `failed` — tiles that failed to load * - `inFrustum` — tiles inside the camera frustum after the last update * - `used` — tiles visited during the last traversal * - `active` — tiles currently set as active * - `visible` — tiles currently visible * @type {Object} */ this.stats = { inCacheSinceLoad: 0, inCache: 0, queued: 0, downloading: 0, parsing: 0, loaded: 0, failed: 0, inFrustum: 0, used: 0, active: 0, visible: 0, tilesProcessed: 0, }; this.frameCount = 0; // callbacks this._dispatchNeedsUpdateEvent = throttle( () => { this.dispatchEvent( { type: 'needs-update' } ); } ); // options /** * Target screen-space error in pixels to aim for when updating the geometry. Tiles will * not render if they are below this level of screen-space error. See the * {@link https://github.com/CesiumGS/3d-tiles/tree/master/specification#geometric-error geometric error section} * of the 3D Tiles specification for more information. * @type {number} * @default 16 */ this.errorTarget = 16.0; /** * "Active tiles" are those that are loaded and available but not necessarily visible. * These tiles are useful for raycasting off-camera or for casting shadows. Active tiles * not currently in a camera frustum are removed from the scene as an optimization. * Setting this to `true` keeps them in the scene so they can be rendered from an outside * camera view not accounted for by the tiles renderer. * @type {boolean} * @default false */ this.displayActiveTiles = false; /** * Maximum depth in the tile hierarchy to traverse. Tiles deeper than this are skipped. * @type {number} * @default Infinity */ this.maxDepth = Infinity; /** * **Experimental.** When `true`, sibling tiles are loaded together to prevent gaps during * camera movement. When `false`, only visible tiles are loaded, minimizing memory but * potentially causing brief gaps during rapid movement. Implicitly treated as `true` when * `loadAncestors` is enabled. * @type {boolean} * @default true */ this.loadSiblings = true; /** * **Experimental.** When `true`, ancestor tiles are queued for download and displayed as a * fallback while children are loading — similar to the behavior of the standard load * strategy. Increases memory usage but provides smoother transitions on first load. * Implicitly enables sibling loading to prevent flickering during camera movement. * @type {boolean} * @default true */ this.loadAncestors = true; /** * The number of tiles to process immediately when traversing the tile set to determine * what to render. Lower numbers prevent frame hiccups caused by processing too many tiles * at once when a new tile set is available, while higher values process more tiles * immediately so data can be downloaded and displayed sooner. * @type {number} * @default 250 */ this.maxTilesProcessed = 250; } // Plugins /** * Registers a plugin with this renderer. Plugins are inserted in priority order and * receive lifecycle callbacks throughout the tile loading and rendering process. * A plugin instance may only be registered to one renderer at a time. * @param {Object} plugin */ registerPlugin( plugin ) { if ( plugin[ PLUGIN_REGISTERED ] === true ) { throw new Error( 'TilesRendererBase: A plugin can only be registered to a single tileset' ); } // 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 ); } } /** * Removes a registered plugin. Calls `plugin.dispose()` if defined. * Accepts either the plugin instance or its string name. * Returns true if the plugin was found and removed. * @param {Object|string} plugin * @returns {boolean} */ unregisterPlugin( plugin ) { const plugins = this.plugins; if ( typeof plugin === 'string' ) { plugin = this.getPluginByName( plugin ); } if ( plugins.includes( plugin ) ) { const index = plugins.indexOf( plugin ); plugins.splice( index, 1 ); if ( plugin.dispose ) { plugin.dispose(); } return true; } return false; } /** * Returns the first registered plugin whose `name` property matches, or null. * @param {string} name * @returns {Object|null} */ getPluginByName( name ) { return this.plugins.find( p => p.name === name ) || null; } 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 ); } // Public API /** * Iterates over all tiles in the loaded hierarchy. `beforecb` is called before * descending into a tile's children; returning true from it skips the subtree. * `aftercb` is called after all children have been visited. * @param {TileBeforeCallback|null} [beforecb] * @param {TileAfterCallback|null} [aftercb] */ 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 ); } /** * Collects attribution data from all registered plugins into `target` and returns it. * @param {Array<{type: string, value: any}>} [target] * @returns {Array<{type: string, value: any}>} */ getAttributions( target = [] ) { this.invokeAllPlugins( plugin => plugin !== this && plugin.getAttributions && plugin.getAttributions( target ) ); return target; } /** * Runs the tile traversal and update loop. Should be called once per frame after * camera matrices have been updated. Triggers tile loading, visibility updates, * and LRU cache eviction. */ update() { // load root 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-tileset', tileset: root, url: processedUrl, } ); this.dispatchEvent( { type: 'load-root-tileset', 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; } // check if the plugins that can block the tile updates require it let needsUpdate = null; this.invokeAllPlugins( plugin => { if ( plugin.doTilesNeedUpdate ) { const res = plugin.doTilesNeedUpdate(); if ( needsUpdate === null ) { needsUpdate = res; } else { needsUpdate = Boolean( needsUpdate || res ); } } } ); if ( needsUpdate === false ) { this.dispatchEvent( { type: 'update-before' } ); this.dispatchEvent( { type: 'update-after' } ); return; } // follow through with the update this.dispatchEvent( { type: 'update-before' } ); // stats.inFrustum = 0; stats.used = 0; stats.active = 0; stats.visible = 0; stats.tilesProcessed = 0; this.frameCount ++; usedSet.forEach( tile => lruCache.markUnused( tile ) ); usedSet.clear(); // prepare for traversal this.prepareForTraversal(); // run traversal runTraversal( root, this ); // remove any tiles that are loading but no longer used this.removeUnusedPendingTiles(); // TODO: This will only sort for one tileset. We may want to store this queue on the // LRUCache so multiple tilesets 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; } this.dispatchEvent( { type: 'update-after' } ); } /** * Resets any tiles that previously failed to load so they will be retried on the next `update`. */ 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.internal.loadingState === FAILED ) { tile.internal.loadingState = UNLOADED; } }, null, false ); stats.failed = 0; } calculateTileViewErrorWithPlugin( tile, target ) { // calculate camera view error this.calculateTileViewError( tile, target ); // TODO: this logic is extremely complex. It may be more simple to have the plugin // return a "should mask" field that indicates its "false" values should be respected // rather than the function returning a "no-op" boolean. // check the plugin visibility - each plugin will mask between themselves let inRegion = null; let inRegionError = 0; let inRegionDistance = Infinity; this.invokeAllPlugins( plugin => { if ( plugin !== this && plugin.calculateTileViewError ) { // if function returns false it means "no operation" regionErrorTarget.inView = true; regionErrorTarget.error = 0; regionErrorTarget.distance = Infinity; if ( plugin.calculateTileViewError( tile, regionErrorTarget ) ) { if ( inRegion === null ) { inRegion = true; } // Plugins can set "inView" to false in order to mask the visible tiles inRegion = inRegion && regionErrorTarget.inView; if ( regionErrorTarget.inView ) { inRegionDistance = Math.min( inRegionDistance, regionErrorTarget.distance ); inRegionError = Math.max( inRegionError, regionErrorTarget.error ); } } } } ); if ( target.inView && inRegion !== false ) { // if the tile is in camera view and we haven't encountered a region (null) or // the region is in view (true). regionInView === false means the tile is masked out. target.error = Math.max( target.error, inRegionError ); target.distanceFromCamera = Math.min( target.distanceFromCamera, inRegionDistance ); } else if ( inRegion ) { // if the tile is in a region then display it target.inView = true; target.error = inRegionError; target.distanceFromCamera = inRegionDistance; } else { // otherwise write variables for load priority target.inView = false; } } /** * Disposes all loaded tiles and unregisters all plugins. The renderer should not * be used after calling this. */ dispose() { // dispose of all the plugins const plugins = [ ...this.plugins ]; 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 = { queued: 0, parsing: 0, downloading: 0, failed: 0, inFrustum: 0, traversed: 0, used: 0, active: 0, visible: 0, }; this.frameCount = 0; this.loadingTiles.clear(); } // Overrideable calculateBytesUsed( scene, tile ) { return 0; } /** * Dispatches an event to all registered listeners for the given event type. * @param {{ type: string }} e */ dispatchEvent( e ) {} /** * Registers a listener for the given event type. * @param {string} name * @param {EventCallback} callback */ addEventListener( name, callback ) {} /** * Removes a previously registered event listener. * @param {string} name * @param {EventCallback} callback */ removeEventListener( name, callback ) {} parseTile( buffer, tile, extension ) { return null; } prepareForTraversal() {} disposeTile( tile ) { // TODO: // The way this is structured means that plugin dispose tile functions will be fired first, then // visibility changes. This ordering is not intuitive. // Need to mirror the "traverseFunctions" behavior for empty tiles (eg internal tile sets) if ( tile.traversal.visible ) { if ( tile.internal.hasRenderableContent ) { this.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, false ) ); } else { this.invokeOnePlugin( plugin => plugin.setEmptyTileVisible && plugin.setEmptyTileVisible( tile, false ) ); } tile.traversal.visible = false; } if ( tile.traversal.active && tile.internal.hasRenderableContent ) { this.invokeOnePlugin( plugin => plugin.setTileActive && plugin.setTileActive( tile, false ) ); } tile.traversal.active = false; const { scene } = tile.engineData; if ( scene ) { this.dispatchEvent( { type: 'dispose-model', scene, tile, } ); } } preprocessNode( tile, tilesetDir, parentTile = null ) { this.processedTiles.add( tile ); this.stats.tilesProcessed ++; 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 || []; // Initialize internal data tile.internal = { hasContent: false, hasRenderableContent: false, hasUnrenderableContent: false, loadingState: UNLOADED, basePath: tilesetDir, depth: - 1, depthFromRenderedParent: - 1, isVirtual: false, virtualChildCount: 0, renderer: this, // preserve any pre-seeded fields ...tile.internal, }; if ( tile.content?.uri ) { // "content" should only indicate loadable meshes, not external tilesets const extension = getUrlExtension( tile.content.uri ); const hasUnrenderableContent = Boolean( extension && /json$/.test( extension ) ); tile.internal.hasContent = true; tile.internal.hasUnrenderableContent = hasUnrenderableContent; tile.internal.hasRenderableContent = ! hasUnrenderableContent; } else { tile.internal.hasContent = false; tile.internal.hasUnrenderableContent = false; tile.internal.hasRenderableContent = false; } // Increment parent's children processed counter if ( parentTile ) { tile.internal.depth = parentTile.internal.depth + 1; tile.internal.depthFromRenderedParent = parentTile.internal.depthFromRenderedParent + ( tile.internal.hasRenderableContent ? 1 : 0 ); } else { tile.internal.depth = 0; tile.internal.depthFromRenderedParent = tile.internal.hasRenderableContent ? 1 : 0; } // Initialize traversal data tile.traversal = { distanceFromCamera: Infinity, error: Infinity, inFrustum: false, wasInFrustum: false, isLeaf: false, used: false, usedLastFrame: false, visible: false, wasSetVisible: false, active: false, wasSetActive: false, allChildrenReady: false, allChildrenLoaded: false, kicked: false, allUsedChildrenProcessed: false, lastFrameVisited: - 1, }; if ( parentTile === null ) { tile.refine = tile.refine || 'REPLACE'; } else { tile.refine = tile.refine || parentTile.refine; } // Initialize engineData data structure with engine-agnostic fields tile.engineData = { scene: null, metadata: null, boundingVolume: null, }; // Backwards compatibility: cached is an alias for engineData Object.defineProperty( tile, 'cached', { get() { console.warn( 'TilesRenderer: "tile.cached" field has been renamed to "tile.engineData".' ); return this.engineData; }, enumerable: false, configurable: true, } ); 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 ); this.dispatchEvent( { type: 'tile-visibility-change', scene: tile.engineData.scene, tile, visible, } ); } calculateTileViewError( tile, target ) { // retrieve whether the tile is visible, screen space error, and distance to camera // set "inView", "error", "distance" } removeUnusedPendingTiles() { const { lruCache, loadingTiles } = this; // cannot delete items while iterating over a set const toRemove = []; for ( const tile of loadingTiles ) { // we only remove tiles that are QUEUED to avoid cancelling tiles that may already be nearly downloaded // as the camera moves if ( ! lruCache.isUsed( tile ) && tile.internal.loadingState === QUEUED ) { toRemove.push( tile ); } } for ( let i = 0; i < toRemove.length; i ++ ) { lruCache.remove( toRemove[ i ] ); } } // Private Functions queueTileForDownload( tile ) { if ( tile.internal.loadingState !== UNLOADED || this.lruCache.isFull() ) { 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 tileset traversal this.usedSet.add( tile ); this.lruCache.markUsed( tile ); } fetchData( url, options ) { return fetch( url, options ); } ensureChildrenArePreprocessed( tile, forceImmediate = this.stats.tilesProcessed < this.maxTilesProcessed ) { const children = tile.children; if ( children.length === 0 || children[ children.length - 1 ].traversal ) { return; } const processChildren = children => { for ( let i = 0, l = children.length; i < l; i ++ ) { const child = children[ i ]; if ( child && ! child.traversal ) { this.preprocessNode( child, tile.internal.basePath, tile ); } } }; // process children immediately up to a max number of tiles during traversal if ( forceImmediate ) { this.processNodeQueue.remove( tile ); processChildren( children ); } else { if ( ! this.processNodeQueue.has( tile ) ) { this.processNodeQueue.add( tile, tile => { processChildren( tile.children ); this._dispatchNeedsUpdateEvent(); } ); } } } // returns the total bytes used for by the given tile as reported by all plugins getBytesUsed( tile ) { let bytes = 0; this.invokeAllPlugins( plugin => { if ( plugin.calculateBytesUsed ) { bytes += plugin.calculateBytesUsed( tile, tile.engineData.scene ) || 0; } } ); return bytes; } // force a recalculation of the tile or all tiles if no tile is provided recalculateBytesUsed( tile = null ) { const { lruCache, processedTiles } = this; if ( tile === null ) { lruCache.itemSet.forEach( item => { if ( processedTiles.has( item ) ) { lruCache.setMemoryUsage( item, this.getBytesUsed( item ) ); } } ); } else { lruCache.setMemoryUsage( tile, this.getBytesUsed( tile ) ); } } 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 tileset root const pr = this .invokeOnePlugin( plugin => plugin.fetchData && plugin.fetchData( processedUrl, this.fetchOptions ) ) .then( res => { if ( ! ( res instanceof Response ) ) { return res; } else 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.internal.loadingState !== UNLOADED ) { return; } let isExternalTileset = false; let externalTileset = null; let url = new URL( tile.content.uri, tile.internal.basePath + '/' ).toString(); this.invokeAllPlugins( plugin => url = plugin.preprocessURL ? plugin.preprocessURL( url, tile ) : url ); const stats = this.stats; const lruCache = this.lruCache; const downloadQueue = this.downloadQueue; const parseQueue = this.parseQueue; const loadingTiles = this.loadingTiles; const extension = getUrlExtension( url ); // 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; } 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.internal.loadingState === QUEUED ) { stats.queued --; } else if ( t.internal.loadingState === LOADING ) { stats.downloading --; } else if ( t.internal.loadingState === PARSING ) { stats.parsing --; } else if ( t.internal.loadingState === LOADED ) { stats.loaded --; } t.internal.loadingState = UNLOADED; parseQueue.remove( t ); downloadQueue.remove( t ); loadingTiles.delete( 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' } ); } lruCache.setMemoryUsage( tile, this.getBytesUsed( tile ) ); this.cachedSinceLoadComplete.add( tile ); stats.inCacheSinceLoad ++; stats.inCache ++; stats.queued ++; tile.internal.loadingState = QUEUED; loadingTiles.add( tile ); // queue the download and parse return downloadQueue.add( tile, downloadTile => { if ( signal.aborted ) { return Promise.resolve(); } tile.internal.loadingState = LOADING; stats.downloading ++; stats.queued --; const res = this.invokeOnePlugin( plugin => plugin.fetchData && plugin.fetchData( url, { ...this.fetchOptions, signal } ) ); this.dispatchEvent( { type: 'tile-download-start', tile, url, get uri() { console.warn( 'tile-download-start event: "uri" has been renamed to "url".' ); return this.url; }, } ); return res; } ) .then( res => { if ( signal.aborted ) { return; } if ( ! ( res instanceof Response ) ) { return res; } else 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.internal.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, url, 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, url, signal ) ); } } ); } ) .then( () => { // if it has been unloaded then the tile has been disposed if ( signal.aborted ) { return; } stats.parsing --; stats.loaded ++; tile.internal.loadingState = LOADED; loadingTiles.delete( tile ); 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. const bytesUsed = this.getBytesUsed( tile ); if ( lruCache.getMemoryUsage( tile ) === 0 && bytesUsed > 0 && lruCache.isFull() ) { // 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 ); return; } // update memory lruCache.setMemoryUsage( tile, bytesUsed ); // dispatch an event indicating that this model has completed and that a new // call to "update" is needed. this.dispatchEvent( { type: 'needs-update' } ); if ( isExternalTileset ) { this.dispatchEvent( { type: 'load-tileset', tileset: externalTileset, url, } ); } if ( tile.engineData.scene ) { this.dispatchEvent( { type: 'load-model', scene: tile.engineData.scene, tile, url, } ); } } ) .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.internal.loadingState === QUEUED ) { stats.queued --; } else if ( tile.internal.loadingState === LOADING ) { stats.downloading --; } else if ( tile.internal.loadingState === PARSING ) { stats.parsing --; } else if ( tile.internal.loadingState === LOADED ) { stats.loaded --; } stats.failed ++; console.error( `TilesRenderer : Failed to load tile at url "${ tile.content.uri }".` ); console.error( error ); tile.internal.loadingState = FAILED; loadingTiles.delete( tile ); lruCache.setLoaded( tile, true ); this.dispatchEvent( { type: 'load-error', tile, error, url, } ); } else { lruCache.remove( tile ); } } ); } }