3d-tiles-renderer
Version:
https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification
278 lines (171 loc) • 5.91 kB
JavaScript
import { LRUCache } from '3d-tiles-renderer/core';
// Plugin that disposes tiles on unload to remove them from the GPU, saving memory
// TODO:
// - abstract the "tile visible" callback so fade tiles can call it when tiles are _actually_ marked as non-visible
/**
* Plugin that unloads geometry, textures, and materials of any given tile when its
* visibility changes to non-visible, freeing GPU memory. The model data still exists on
* the CPU until it is completely removed from the cache, allowing it to be re-uploaded
* without re-fetching.
* @param {Object} [options]
* @param {number} [options.delay=0] Milliseconds to wait after a tile is hidden before freeing its GPU data. Useful to avoid thrashing when the camera is moving.
* @param {number} [options.bytesTarget=0] Target GPU byte budget to unload down to. `0` means unload with no budget limit.
*/
export class UnloadTilesPlugin {
set delay( v ) {
this.deferCallbacks.delay = v;
}
get delay() {
return this.deferCallbacks.delay;
}
set bytesTarget( v ) {
this.lruCache.minBytesSize = v;
}
get bytesTarget() {
return this.lruCache.minBytesSize;
}
/**
* The number of bytes currently uploaded to the GPU for rendering. Compare to
* `lruCache.cachedBytes` which reports all downloaded bytes including those not
* yet on the GPU.
* @type {number}
*/
get estimatedGpuBytes() {
return this.lruCache.cachedBytes;
}
constructor( options = {} ) {
const {
delay = 0,
bytesTarget = 0,
} = options;
this.name = 'UNLOAD_TILES_PLUGIN';
this.tiles = null;
this.lruCache = new LRUCache();
this.deferCallbacks = new DeferCallbackManager();
this.delay = delay;
this.bytesTarget = bytesTarget;
}
init( tiles ) {
this.tiles = tiles;
const { lruCache, deferCallbacks } = this;
const unloadCallback = tile => {
// trigger a tile unload from the GPU if it's not currently visible
const scene = tile.engineData.scene;
const visible = tiles.visibleTiles.has( tile );
if ( ! visible ) {
// extra visible check in case another plugin or system has forced it to be visible
tiles.invokeOnePlugin( plugin => plugin.unloadTileFromGPU && plugin.unloadTileFromGPU( scene, tile ) );
}
};
this._onUpdateBefore = () => {
// update lruCache in "update" in case the callback values change
lruCache.unloadPriorityCallback = tiles.lruCache.unloadPriorityCallback;
// adjust the settings so we don't reject tiles added
lruCache.minSize = Infinity;
lruCache.maxSize = Infinity;
lruCache.maxBytesSize = Infinity;
// unload all tiles possible at once
lruCache.unloadPercent = 1;
// do not run mark unused without an explicit call
lruCache.autoMarkUnused = false;
};
this._onVisibilityChangeCallback = ( { tile, scene, visible } ) => {
if ( visible ) {
// if the tile is visible then do not trigger disposal - for the tile to have at least 1 byte of
// memory usage to ensure the lru cache will always remove the item to reduce memory pressure
lruCache.add( tile, unloadCallback );
lruCache.setMemoryUsage( tile, tiles.calculateBytesUsed( tile, scene ) || 1 );
tiles.markTileUsed( tile );
deferCallbacks.cancel( tile );
} else {
// mark the tile as unused in our cache and trigger an unload after the delay
deferCallbacks.run( tile );
}
};
this._onDisposeModel = ( { tile } ) => {
lruCache.remove( tile );
deferCallbacks.cancel( tile );
};
deferCallbacks.callback = tile => {
// try to unload the tile up to our limit
lruCache.markUnused( tile );
lruCache.scheduleUnload();
};
// initialize all existing tiles
tiles.forEachLoadedModel( ( scene, tile ) => {
const visible = tiles.visibleTiles.has( tile );
this._onVisibilityChangeCallback( { tile, visible } );
} );
tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChangeCallback );
tiles.addEventListener( 'update-before', this._onUpdateBefore );
tiles.addEventListener( 'dispose-model', this._onDisposeModel );
}
unloadTileFromGPU( scene, tile ) {
if ( scene ) {
// disposes of all GPU memory and materials associated with the tile but does not
// close any image bitmaps so we can reupload them as needed
scene.traverse( c => {
if ( c.material ) {
const material = c.material;
material.dispose();
for ( const key in material ) {
const value = material[ key ];
if ( value && value.isTexture ) {
value.dispose();
}
}
}
if ( c.geometry ) {
c.geometry.dispose();
}
} );
}
}
dispose() {
const { lruCache, tiles, deferCallbacks } = this;
tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChangeCallback );
tiles.removeEventListener( 'update-before', this._onUpdateBefore );
tiles.removeEventListener( 'dispose-model', this._onDisposeModel );
deferCallbacks.cancelAll();
// clear the lru cache
lruCache.minBytesSize = 0;
lruCache.minSize = 0;
lruCache.maxSize = 0;
lruCache.markAllUnused();
lruCache.scheduleUnload();
}
}
// Manager for running callbacks after a certain amount of time
class DeferCallbackManager {
constructor( callback = () => {} ) {
this.map = new Map();
this.callback = callback;
this.delay = 0;
}
run( tile ) {
const { map, delay } = this;
if ( map.has( tile ) ) {
throw new Error( 'DeferCallbackManager: Callback already initialized.' );
}
if ( delay === 0 ) {
this.callback( tile );
} else {
map.set( tile, setTimeout( () => {
this.callback( tile );
map.delete( tile );
}, delay ) );
}
}
cancel( tile ) {
const { map } = this;
if ( map.has( tile ) ) {
clearTimeout( map.get( tile ) );
map.delete( tile );
}
}
cancelAll() {
this.map.forEach( ( value, tile ) => {
this.cancel( tile );
} );
}
}