diff --git a/example/src/plugins/UnloadTilesPlugin.js b/example/src/plugins/UnloadTilesPlugin.js new file mode 100644 index 000000000..ec0f11ebc --- /dev/null +++ b/example/src/plugins/UnloadTilesPlugin.js @@ -0,0 +1,95 @@ +import { estimateBytesUsed } from '../../../src/three/utilities.js'; + +// 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 +// - add a memory unload function to the tiles renderer that can be called and reacted to by any plugin including BatchedMesh, +// though this may prevent different options. Something like a subfunction that "disposeTile" calls without full disposal. +export class UnloadTilesPlugin { + + constructor() { + + this.name = 'UNLOAD_TILES_PLUGIN'; + + this.tiles = null; + this.estimatedGpuBytes = 0; + + } + + init( tiles ) { + + this.tiles = tiles; + + this._onVisibilityChangeCallback = ( { scene, visible, tile } ) => { + + if ( scene ) { + + const size = estimateBytesUsed( scene ); + this.estimatedGpuBytes += visible ? size : - size; + + if ( ! visible ) { + + tiles.invokeOnePlugin( plugin => plugin.unloadTileFromGPU && plugin.unloadTileFromGPU( scene, tile ) ); + + } + + } + + }; + + tiles.forEachLoadedModel( ( scene, tile ) => { + + const visible = tiles.visibleSet.has( tile ); + this._onVisibilityChangeCallback( { scene, visible } ); + + } ); + + tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChangeCallback ); + + } + + unloadTileFromGPU( scene, tile ) { + + if ( scene ) { + + 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() { + + this.tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChangeCallback ); + this.estimatedGpuBytes = 0; + + } + +} diff --git a/example/src/plugins/batched/BatchedTilesPlugin.js b/example/src/plugins/batched/BatchedTilesPlugin.js index 9f1f87d5a..4d4b9d6de 100644 --- a/example/src/plugins/batched/BatchedTilesPlugin.js +++ b/example/src/plugins/batched/BatchedTilesPlugin.js @@ -1,4 +1,4 @@ -import { WebGLArrayRenderTarget, MeshBasicMaterial, Group, DataTexture, REVISION } from 'three'; +import { WebGLArrayRenderTarget, MeshBasicMaterial, DataTexture, REVISION } from 'three'; import { FullScreenQuad } from 'three/examples/jsm/postprocessing/Pass.js'; import { ExpandingBatchedMesh } from './ExpandingBatchedMesh.js'; import { convertMapToArrayTexture, isColorWhite } from './utilities.js'; @@ -23,6 +23,7 @@ export class BatchedTilesPlugin { indexCount: 2000, expandPercent: 0.25, maxInstanceCount: Infinity, + discardOriginalContent: true, material: null, renderer: null, @@ -30,6 +31,7 @@ export class BatchedTilesPlugin { }; this.name = 'BATCHED_MESH_PLUGIN'; + this.priority = - 1; // limit the amount of instances to the size of a 3d texture to avoid over flowing the const gl = options.renderer.getContext(); @@ -42,6 +44,7 @@ export class BatchedTilesPlugin { this.expandPercent = options.expandPercent; this.maxInstanceCount = Math.min( options.maxInstanceCount, gl.getParameter( gl.MAX_3D_TEXTURE_SIZE ) ); this.renderer = options.renderer; + this.discardOriginalContent = options.discardOriginalContent; // local variables this.batchedMesh = null; @@ -56,139 +59,14 @@ export class BatchedTilesPlugin { init( tiles ) { - this._onLoadModel = ( { scene, tile } ) => { + this._onDisposeModel = ( { scene, tile } ) => { - // find the meshes in the scene - const meshes = []; - scene.traverse( c => { - - if ( c.isMesh ) { - - meshes.push( c ); - - } - - } ); - - // don't add the geometry if it doesn't have the right attributes - let hasCorrectAttributes = true; - meshes.forEach( mesh => { - - if ( this.batchedMesh && hasCorrectAttributes ) { - - const attrs = mesh.geometry.attributes; - const batchedAttrs = this.batchedMesh.geometry.attributes; - for ( const key in batchedAttrs ) { - - if ( ! ( key in attrs ) ) { - - hasCorrectAttributes = false; - return; - - } - - } - - } - - } ); - - const canAddMeshes = ! this.batchedMesh || this.batchedMesh.instanceCount + meshes.length <= this.maxInstanceCount; - if ( hasCorrectAttributes && canAddMeshes ) { - - // TODO: ideally we could just set these to null - tile.cached.scene = new Group(); - tile.cached.materials = []; - tile.cached.geometries = []; - tile.cached.textures = []; - - scene.updateMatrixWorld(); - - const instanceIds = []; - meshes.forEach( mesh => { - - this.initBatchedMesh( mesh ); - - const { geometry, material } = mesh; - const { batchedMesh, expandPercent } = this; - - // assign expandPercent in case it has changed - batchedMesh.expandPercent = expandPercent; - - const geometryId = batchedMesh.addGeometry( geometry, this.vertexCount, this.indexCount ); - const instanceId = batchedMesh.addInstance( geometryId ); - instanceIds.push( instanceId ); - batchedMesh.setMatrixAt( instanceId, mesh.matrixWorld ); - batchedMesh.setVisibleAt( instanceId, false ); - if ( ! isColorWhite( material.color ) ) { - - material.color.setHSL( Math.random(), 0.5, 0.5 ); - batchedMesh.setColorAt( instanceId, material.color ); - - } - - // render the material - const texture = material.map; - if ( texture ) { - - this.assignTextureToLayer( texture, instanceId ); - - } else { - - this.assignTextureToLayer( _whiteTex, instanceId ); - - } - - } ); - - this._tileToInstanceId.set( tile, instanceIds ); - - } - - }; - - this._onDisposeModel = ( { tile } ) => { - - if ( this._tileToInstanceId.has( tile ) ) { - - const instanceIds = this._tileToInstanceId.get( tile ); - this._tileToInstanceId.delete( tile ); - instanceIds.forEach( instanceId => { - - this.batchedMesh.deleteInstance( instanceId ); - - } ); - - } - - }; - - this._onVisibilityChange = ( { tile, visible } ) => { - - if ( this._tileToInstanceId.has( tile ) ) { - - const instanceIds = this._tileToInstanceId.get( tile ); - instanceIds.forEach( instanceId => { - - this.batchedMesh.setVisibleAt( instanceId, visible ); - - } ); - - } + this.removeSceneFromBatchedMesh( scene, tile ); }; // register events - tiles.addEventListener( 'load-model', this._onLoadModel ); tiles.addEventListener( 'dispose-model', this._onDisposeModel ); - tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); - - // prepare all already loaded geometry - tiles.forEachLoadedModel( ( scene, tile ) => { - - this._onLoadModel( { scene, tile } ); - - } ); this.tiles = tiles; } @@ -237,6 +115,66 @@ export class BatchedTilesPlugin { } + setTileVisible( tile, visible ) { + + const scene = tile.cached.scene; + if ( visible ) { + + // Add tile set to the batched mesh if it hasn't been added already + this.addSceneToBatchedMesh( scene, tile ); + + } + + if ( this._tileToInstanceId.has( tile ) ) { + + const instanceIds = this._tileToInstanceId.get( tile ); + instanceIds.forEach( instanceId => { + + this.batchedMesh.setVisibleAt( instanceId, visible ); + + } ); + + // TODO: this should be handled by the base tiles renderer + const tiles = this.tiles; + if ( visible ) { + + tiles.visibleTiles.add( tile ); + + } else { + + tiles.visibleTiles.delete( tile ); + + } + + // dispatch the event that is blocked otherwise + tiles.dispatchEvent( { + type: 'tile-visibility-change', + scene, + tile, + visible, + } ); + + return true; + + } + + return false; + + } + + unloadTileFromGPU( scene, tile ) { + + if ( ! this.discardOriginalContent && this._tileToInstanceId.has( tile ) ) { + + this.removeSceneFromBatchedMesh( scene, tile ); + return true; + + } + + return false; + + } + // render the given into the given layer assignTextureToLayer( texture, layer ) { @@ -250,6 +188,8 @@ export class BatchedTilesPlugin { _textureRenderQuad.material.map = texture; _textureRenderQuad.render( renderer ); + // TODO: perform a copy if the texture is already the appropriate size + // reset state renderer.setRenderTarget( currentRenderTarget ); _textureRenderQuad.material.map = null; @@ -290,6 +230,122 @@ export class BatchedTilesPlugin { } + removeSceneFromBatchedMesh( scene, tile ) { + + if ( this._tileToInstanceId.has( tile ) ) { + + const instanceIds = this._tileToInstanceId.get( tile ); + this._tileToInstanceId.delete( tile ); + instanceIds.forEach( instanceId => { + + this.batchedMesh.deleteInstance( instanceId ); + + } ); + + } + + } + + addSceneToBatchedMesh( scene, tile ) { + + if ( this._tileToInstanceId.has( tile ) ) { + + return; + + } + + // find the meshes in the scene + const meshes = []; + scene.traverse( c => { + + if ( c.isMesh ) { + + meshes.push( c ); + + } + + } ); + + // don't add the geometry if it doesn't have the right attributes + let hasCorrectAttributes = true; + meshes.forEach( mesh => { + + if ( this.batchedMesh && hasCorrectAttributes ) { + + const attrs = mesh.geometry.attributes; + const batchedAttrs = this.batchedMesh.geometry.attributes; + for ( const key in batchedAttrs ) { + + if ( ! ( key in attrs ) ) { + + hasCorrectAttributes = false; + return; + + } + + } + + } + + } ); + + const canAddMeshes = ! this.batchedMesh || this.batchedMesh.instanceCount + meshes.length <= this.maxInstanceCount; + if ( hasCorrectAttributes && canAddMeshes ) { + + if ( this.discardOriginalContent ) { + + tile.cached.scene = null; + tile.cached.materials = []; + tile.cached.geometries = []; + tile.cached.textures = []; + + } + + scene.updateMatrixWorld(); + + const instanceIds = []; + meshes.forEach( mesh => { + + this.initBatchedMesh( mesh ); + + const { geometry, material } = mesh; + const { batchedMesh, expandPercent } = this; + + // assign expandPercent in case it has changed + batchedMesh.expandPercent = expandPercent; + + const geometryId = batchedMesh.addGeometry( geometry, this.vertexCount, this.indexCount ); + const instanceId = batchedMesh.addInstance( geometryId ); + instanceIds.push( instanceId ); + batchedMesh.setMatrixAt( instanceId, mesh.matrixWorld ); + batchedMesh.setVisibleAt( instanceId, false ); + if ( ! isColorWhite( material.color ) ) { + + material.color.setHSL( Math.random(), 0.5, 0.5 ); + batchedMesh.setColorAt( instanceId, material.color ); + + } + + // render the material + const texture = material.map; + if ( texture ) { + + this.assignTextureToLayer( texture, instanceId ); + + } else { + + this.assignTextureToLayer( _whiteTex, instanceId ); + + } + + } ); + + this._tileToInstanceId.set( tile, instanceIds ); + + } + + } + // Override raycasting per tile to defer to the batched mesh raycastTile( tile, scene, raycaster, intersects ) { @@ -328,9 +384,7 @@ export class BatchedTilesPlugin { } - tiles.removeEventListener( 'load-model', this._onLoadModel ); tiles.removeEventListener( 'dispose-model', this._onDisposeModel ); - tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); } diff --git a/src/base/TilesRendererBase.js b/src/base/TilesRendererBase.js index 7250cfcf3..8a681dc08 100644 --- a/src/base/TilesRendererBase.js +++ b/src/base/TilesRendererBase.js @@ -166,7 +166,24 @@ export class TilesRendererBase { } - this.plugins.push( plugin ); + // insert the plugin based on the priority registered on the plugin + const plugins = this.plugins; + const priority = plugin.priority || 0; + let insertionPoint = 0; + for ( let i = 0; i < plugins.length; i ++ ) { + + insertionPoint = i; + + const otherPriority = plugins[ i ].priority || 0; + if ( otherPriority > priority ) { + + break; + + } + + } + + plugins.splice( insertionPoint, 0, plugin ); plugin[ PLUGIN_REGISTERED ] = true; if ( plugin.init ) { @@ -363,9 +380,10 @@ export class TilesRendererBase { disposeTile( tile ) { + // TODO: are these necessary? Are we disposing tiles when they are currently visible? if ( tile.__visible ) { - this.setTileVisible( tile, false ); + this.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, false ) ); tile.__visible = false; } diff --git a/src/base/traverseFunctions.js b/src/base/traverseFunctions.js index 32654f4db..38af26b4d 100644 --- a/src/base/traverseFunctions.js +++ b/src/base/traverseFunctions.js @@ -443,7 +443,7 @@ export function toggleTiles( tile, renderer ) { if ( tile.__wasSetVisible !== setVisible ) { - renderer.setTileVisible( tile, setVisible ); + renderer.invokeOnePlugin( plugin => plugin.setTileVisible && plugin.setTileVisible( tile, setVisible ) ); } diff --git a/src/plugins/README.md b/src/plugins/README.md index 026965cae..8bc8d46fc 100644 --- a/src/plugins/README.md +++ b/src/plugins/README.md @@ -574,5 +574,23 @@ Available options are as follows: // The material to use for the BatchedMesh. The material of the first tile rendered with be used if not set. material: null, + + // If true then the original scene geometry is automatically discarded after adding the geometry to the batched mesh to save memory. + // This must be set to "false" if being used with plugins such as "UnloadTilesPlugin". + discardOriginalContent: true } ``` + +## UnloadTilesPlugin + +_available in the examples directory_ + +Plugin that unloads geometry, textures, and materials of any given tile when the visibility changes to non-visible to save GPU memory. The model still exists on the CPU until it is completely removed from the cache. + +### .estimatedGpuBytes + +```js +estimatedGPUBytes : number +``` + +The number of bytes that are actually uploaded to the GPU for rendering compared to `lruCache.cachedBytes` which reports the amount of texture and geometry buffer bytes actually downloaded. diff --git a/src/plugins/three/TileCompressionPlugin.js b/src/plugins/three/TileCompressionPlugin.js index cdd120c43..45881727d 100644 --- a/src/plugins/three/TileCompressionPlugin.js +++ b/src/plugins/three/TileCompressionPlugin.js @@ -126,6 +126,9 @@ export class TileCompressionPlugin { ...options, }; + this.name = 'TILES_COMPRESSION_PLUGIN'; + this.priority = - 100; + } processTileModel( scene, tile ) { diff --git a/src/three/TilesRenderer.js b/src/three/TilesRenderer.js index eab1e0a46..42161cfc6 100644 --- a/src/three/TilesRenderer.js +++ b/src/three/TilesRenderer.js @@ -818,6 +818,8 @@ export class TilesRenderer extends TilesRendererBase { const parent = cached.scene.parent; // dispose of any textures required by the mesh features extension + // TODO: these are being discarded here to remove the image bitmaps - + // can this be handled in another way? Or more generically? cached.scene.traverse( child => { if ( child.userData.meshFeatures ) { @@ -889,15 +891,27 @@ export class TilesRenderer extends TilesRendererBase { const scene = tile.cached.scene; const visibleTiles = this.visibleTiles; const group = this.group; + + // TODO: move "visibleTiles" to TilesRendererBase if ( visible ) { - group.add( scene ); + if ( scene ) { + + group.add( scene ); + scene.updateMatrixWorld( true ); + + } + visibleTiles.add( tile ); - scene.updateMatrixWorld( true ); } else { - group.remove( scene ); + if ( scene ) { + + group.remove( scene ); + + } + visibleTiles.delete( tile ); }