UNPKG

3d-tiles-renderer

Version:

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

1,068 lines (668 loc) 22.9 kB
import { TilesRendererBase, LoaderUtils } from '3d-tiles-renderer/core'; import { B3DMLoader } from '../loaders/B3DMLoader.js'; import { PNTSLoader } from '../loaders/PNTSLoader.js'; import { I3DMLoader } from '../loaders/I3DMLoader.js'; import { CMPTLoader } from '../loaders/CMPTLoader.js'; import { TilesGroup } from './TilesGroup.js'; import { Matrix4, Vector3, Vector2, Euler, LoadingManager, EventDispatcher, Group, } from 'three'; import { raycastTraverse, raycastTraverseFirstHit } from './raycastTraverse.js'; import { TileBoundingVolume } from '../math/TileBoundingVolume.js'; import { ExtendedFrustum } from '../math/ExtendedFrustum.js'; import { estimateBytesUsed } from '../utils/MemoryUtils.js'; import { WGS84_ELLIPSOID } from '../math/GeoConstants.js'; import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; const _mat = /* @__PURE__ */ new Matrix4(); const _euler = /* @__PURE__ */ new Euler(); // In three.js r165 and higher raycast traversal can be ended early const INITIAL_FRUSTUM_CULLED = Symbol( 'INITIAL_FRUSTUM_CULLED' ); const tempMat = /* @__PURE__ */ new Matrix4(); const tempVector = /* @__PURE__ */ new Vector3(); const tempVector2 = /* @__PURE__ */ new Vector2(); const viewErrorTarget = { inView: false, error: Infinity, }; const X_AXIS = /* @__PURE__ */ new Vector3( 1, 0, 0 ); const Y_AXIS = /* @__PURE__ */ new Vector3( 0, 1, 0 ); function updateFrustumCulled( object, toInitialValue ) { object.traverse( c => { c.frustumCulled = c[ INITIAL_FRUSTUM_CULLED ] && toInitialValue; } ); } export class TilesRenderer extends TilesRendererBase { get autoDisableRendererCulling() { return this._autoDisableRendererCulling; } set autoDisableRendererCulling( value ) { if ( this._autoDisableRendererCulling !== value ) { super._autoDisableRendererCulling = value; this.forEachLoadedModel( ( scene ) => { updateFrustumCulled( scene, ! value ); } ); } } get optimizeRaycast() { return this._optimizeRaycast; } set optimizeRaycast( v ) { console.warn( 'TilesRenderer: The "optimizeRaycast" option has been deprecated.' ); this._optimizeRaycast = v; } constructor( ...args ) { super( ...args ); this.group = new TilesGroup( this ); this.ellipsoid = WGS84_ELLIPSOID.clone(); this.cameras = []; this.cameraMap = new Map(); this.cameraInfo = []; this._optimizeRaycast = true; this._upRotationMatrix = new Matrix4(); this._bytesUsed = new WeakMap(); // flag indicating whether frustum culling should be disabled this._autoDisableRendererCulling = true; this.manager = new LoadingManager(); // saved for event dispatcher functions this._listeners = {}; } addEventListener( type, listener ) { if ( type === 'load-tile-set' ) { console.warn( 'TilesRenderer: "load-tile-set" event has been deprecated. Use "load-tileset" instead.' ); type = 'load-tileset'; } EventDispatcher.prototype.addEventListener.call( this, type, listener ); } hasEventListener( type, listener ) { if ( type === 'load-tile-set' ) { console.warn( 'TilesRenderer: "load-tile-set" event has been deprecated. Use "load-tileset" instead.' ); type = 'load-tileset'; } return EventDispatcher.prototype.hasEventListener.call( this, type, listener ); } removeEventListener( type, listener ) { if ( type === 'load-tile-set' ) { console.warn( 'TilesRenderer: "load-tile-set" event has been deprecated. Use "load-tileset" instead.' ); type = 'load-tileset'; } EventDispatcher.prototype.removeEventListener.call( this, type, listener ); } dispatchEvent( e ) { if ( 'tileset' in e ) { Object.defineProperty( e, 'tileSet', { get() { console.warn( 'TilesRenderer: "event.tileSet" has been deprecated. Use "event.tileset" instead.' ); return e.tileset; }, enumerable: false, configurable: true, } ); } EventDispatcher.prototype.dispatchEvent.call( this, e ); } /* Public API */ getBoundingBox( target ) { if ( ! this.root ) { return false; } const boundingVolume = this.root.cached.boundingVolume; if ( boundingVolume ) { boundingVolume.getAABB( target ); return true; } else { return false; } } getOrientedBoundingBox( targetBox, targetMatrix ) { if ( ! this.root ) { return false; } const boundingVolume = this.root.cached.boundingVolume; if ( boundingVolume ) { boundingVolume.getOBB( targetBox, targetMatrix ); return true; } else { return false; } } getBoundingSphere( target ) { if ( ! this.root ) { return false; } const boundingVolume = this.root.cached.boundingVolume; if ( boundingVolume ) { boundingVolume.getSphere( target ); return true; } else { return false; } } forEachLoadedModel( callback ) { this.traverse( tile => { const scene = tile.cached && tile.cached.scene; if ( scene ) { callback( scene, tile ); } }, null, false ); } raycast( raycaster, intersects ) { if ( ! this.root ) { return; } if ( raycaster.firstHitOnly ) { const hit = raycastTraverseFirstHit( this, this.root, raycaster ); if ( hit ) { intersects.push( hit ); } } else { raycastTraverse( this, this.root, raycaster, intersects ); } } hasCamera( camera ) { return this.cameraMap.has( camera ); } setCamera( camera ) { const cameras = this.cameras; const cameraMap = this.cameraMap; if ( ! cameraMap.has( camera ) ) { cameraMap.set( camera, new Vector2() ); cameras.push( camera ); this.dispatchEvent( { type: 'add-camera', camera } ); return true; } return false; } setResolution( camera, xOrVec, y ) { const cameraMap = this.cameraMap; if ( ! cameraMap.has( camera ) ) { return false; } const width = xOrVec.isVector2 ? xOrVec.x : xOrVec; const height = xOrVec.isVector2 ? xOrVec.y : y; const cameraVec = cameraMap.get( camera ); if ( cameraVec.width !== width || cameraVec.height !== height ) { cameraVec.set( width, height ); this.dispatchEvent( { type: 'camera-resolution-change' } ); } return true; } setResolutionFromRenderer( camera, renderer ) { renderer.getSize( tempVector2 ); return this.setResolution( camera, tempVector2.x, tempVector2.y ); } deleteCamera( camera ) { const cameras = this.cameras; const cameraMap = this.cameraMap; if ( cameraMap.has( camera ) ) { const index = cameras.indexOf( camera ); cameras.splice( index, 1 ); cameraMap.delete( camera ); this.dispatchEvent( { type: 'delete-camera', camera } ); return true; } return false; } /* Overriden */ loadRootTileset( ...args ) { return super.loadRootTileset( ...args ) .then( root => { // cache the gltf tileset rotation matrix const { asset, extensions = {} } = root; const upAxis = asset && asset.gltfUpAxis || 'y'; switch ( upAxis.toLowerCase() ) { case 'x': this._upRotationMatrix.makeRotationAxis( Y_AXIS, - Math.PI / 2 ); break; case 'y': this._upRotationMatrix.makeRotationAxis( X_AXIS, Math.PI / 2 ); break; } // update the ellipsoid based on the extension if ( '3DTILES_ellipsoid' in extensions ) { const ext = extensions[ '3DTILES_ellipsoid' ]; const { ellipsoid } = this; ellipsoid.name = ext.body; if ( ext.radii ) { ellipsoid.radius.set( ...ext.radii ); } else { ellipsoid.radius.set( 1, 1, 1 ); } } return root; } ); } update() { // 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' } ); const group = this.group; const cameras = this.cameras; const cameraMap = this.cameraMap; const cameraInfo = this.cameraInfo; // automatically scale the array of cameraInfo to match the cameras while ( cameraInfo.length > cameras.length ) { cameraInfo.pop(); } while ( cameraInfo.length < cameras.length ) { cameraInfo.push( { frustum: new ExtendedFrustum(), isOrthographic: false, sseDenominator: - 1, // used if isOrthographic:false position: new Vector3(), invScale: - 1, pixelSize: 0, // used if isOrthographic:true } ); } // extract scale of group container tempVector.setFromMatrixScale( group.matrixWorldInverse ); if ( Math.abs( Math.max( tempVector.x - tempVector.y, tempVector.x - tempVector.z ) ) > 1e-6 ) { console.warn( 'ThreeTilesRenderer : Non uniform scale used for tile which may cause issues when calculating screen space error.' ); } // store the camera cameraInfo in the 3d tiles root frame for ( let i = 0, l = cameraInfo.length; i < l; i ++ ) { const camera = cameras[ i ]; const info = cameraInfo[ i ]; const frustum = info.frustum; const position = info.position; const resolution = cameraMap.get( camera ); if ( resolution.width === 0 || resolution.height === 0 ) { console.warn( 'TilesRenderer: resolution for camera error calculation is not set.' ); } // Read the calculated projection matrix directly to support custom Camera implementations const projection = camera.projectionMatrix.elements; // The last element of the projection matrix is 1 for orthographic, 0 for perspective info.isOrthographic = projection[ 15 ] === 1; if ( info.isOrthographic ) { // See OrthographicCamera.updateProjectionMatrix and Matrix4.makeOrthographic: // the view width and height are used to populate matrix elements 0 and 5. const w = 2 / projection[ 0 ]; const h = 2 / projection[ 5 ]; info.pixelSize = Math.max( h / resolution.height, w / resolution.width ); } else { // See PerspectiveCamera.updateProjectionMatrix and Matrix4.makePerspective: // the vertical FOV is used to populate matrix element 5. info.sseDenominator = ( 2 / projection[ 5 ] ) / resolution.height; } // get frustum in group root frame tempMat.copy( group.matrixWorld ); tempMat.premultiply( camera.matrixWorldInverse ); tempMat.premultiply( camera.projectionMatrix ); frustum.setFromProjectionMatrix( tempMat ); // get transform position in group root frame position.set( 0, 0, 0 ); position.applyMatrix4( camera.matrixWorld ); position.applyMatrix4( group.matrixWorldInverse ); } super.update(); this.dispatchEvent( { type: 'update-after' } ); // check for cameras _after_ base update so we can enable pre-loading the root tileset if ( cameras.length === 0 && this.root ) { let found = false; this.invokeAllPlugins( plugin => found = found || Boolean( plugin !== this && plugin.calculateTileViewError ) ); if ( found === false ) { console.warn( 'TilesRenderer: no cameras defined. Cannot update 3d tiles.' ); } } } preprocessNode( tile, tilesetDir, parentTile = null ) { super.preprocessNode( tile, tilesetDir, parentTile ); const transform = new Matrix4(); if ( tile.transform ) { const transformArr = tile.transform; for ( let i = 0; i < 16; i ++ ) { transform.elements[ i ] = transformArr[ i ]; } } if ( parentTile ) { transform.premultiply( parentTile.cached.transform ); } const transformInverse = new Matrix4().copy( transform ).invert(); const boundingVolume = new TileBoundingVolume(); if ( 'sphere' in tile.boundingVolume ) { boundingVolume.setSphereData( ...tile.boundingVolume.sphere, transform ); } if ( 'box' in tile.boundingVolume ) { boundingVolume.setObbData( tile.boundingVolume.box, transform ); } if ( 'region' in tile.boundingVolume ) { boundingVolume.setRegionData( this.ellipsoid, ...tile.boundingVolume.region ); } tile.cached = { transform, transformInverse, active: false, boundingVolume, metadata: null, scene: null, geometry: null, materials: null, textures: null, }; } async parseTile( buffer, tile, extension, uri, abortSignal ) { const cached = tile.cached; const workingPath = LoaderUtils.getWorkingPath( uri ); const fetchOptions = this.fetchOptions; const manager = this.manager; let promise = null; const cachedTransform = cached.transform; const upRotationMatrix = this._upRotationMatrix; const fileType = ( LoaderUtils.readMagicBytes( buffer ) || extension ).toLowerCase(); switch ( fileType ) { case 'b3dm': { const loader = new B3DMLoader( manager ); loader.workingPath = workingPath; loader.fetchOptions = fetchOptions; loader.adjustmentTransform.copy( upRotationMatrix ); promise = loader.parse( buffer ); break; } case 'pnts': { const loader = new PNTSLoader( manager ); loader.workingPath = workingPath; loader.fetchOptions = fetchOptions; promise = loader.parse( buffer ); break; } case 'i3dm': { const loader = new I3DMLoader( manager ); loader.workingPath = workingPath; loader.fetchOptions = fetchOptions; loader.adjustmentTransform.copy( upRotationMatrix ); loader.ellipsoid.copy( this.ellipsoid ); promise = loader.parse( buffer ); break; } case 'cmpt': { const loader = new CMPTLoader( manager ); loader.workingPath = workingPath; loader.fetchOptions = fetchOptions; loader.adjustmentTransform.copy( upRotationMatrix ); loader.ellipsoid.copy( this.ellipsoid ); promise = loader .parse( buffer ) .then( res => res.scene ); break; } // 3DTILES_content_gltf case 'gltf': case 'glb': { const loader = manager.getHandler( 'path.gltf' ) || manager.getHandler( 'path.glb' ) || new GLTFLoader( manager ); loader.setWithCredentials( fetchOptions.credentials === 'include' ); loader.setRequestHeader( fetchOptions.headers || {} ); if ( fetchOptions.credentials === 'include' && fetchOptions.mode === 'cors' ) { loader.setCrossOrigin( 'use-credentials' ); } // assume any pre-registered loader has paths configured as the user desires, but if we're making // a new loader, use the working path during parse to support relative uris on other hosts let resourcePath = loader.resourcePath || loader.path || workingPath; if ( ! /[\\/]$/.test( resourcePath ) && resourcePath.length ) { resourcePath += '/'; } promise = loader.parseAsync( buffer, resourcePath ).then( result => { // glTF files are not guaranteed to include a scene object result.scene = result.scene || new Group(); // apply the local up-axis correction rotation // GLTFLoader seems to never set a transformation on the root scene object so // any transformations applied to it can be assumed to be applied after load // (such as applying RTC_CENTER) meaning they should happen _after_ the z-up // rotation fix which is why "multiply" happens here. const { scene } = result; scene.updateMatrix(); scene.matrix .multiply( upRotationMatrix ) .decompose( scene.position, scene.quaternion, scene.scale ); return result; } ); break; } default: { promise = this.invokeOnePlugin( plugin => plugin.parseToMesh && plugin.parseToMesh( buffer, tile, extension, uri, abortSignal ) ); break; } } // wait for the tile to load const result = await promise; if ( result === null ) { throw new Error( `TilesRenderer: Content type "${ fileType }" not supported.` ); } // get the scene data let scene; let metadata; if ( result.isObject3D ) { scene = result; metadata = null; } else { scene = result.scene; metadata = result; } // ensure the matrix is up to date in case the scene has a transform applied scene.updateMatrix(); scene.matrix.premultiply( cachedTransform ); scene.matrix.decompose( scene.position, scene.quaternion, scene.scale ); // wait for extra processing by plugins if needed await this.invokeAllPlugins( plugin => { return plugin.processTileModel && plugin.processTileModel( scene, tile ); } ); // frustum culling scene.traverse( c => { c[ INITIAL_FRUSTUM_CULLED ] = c.frustumCulled; } ); updateFrustumCulled( scene, ! this.autoDisableRendererCulling ); // collect all original geometries, materials, etc to be disposed of later const materials = []; const geometry = []; const textures = []; scene.traverse( c => { if ( c.geometry ) { geometry.push( c.geometry ); } if ( c.material ) { const material = c.material; materials.push( c.material ); for ( const key in material ) { const value = material[ key ]; if ( value && value.isTexture ) { textures.push( value ); } } } } ); // exit early if a new request has already started if ( abortSignal.aborted ) { // dispose of any image bitmaps that have been opened. // TODO: share this code with the "disposeTile" code below, possibly allow for the tiles // renderer base to trigger a disposal of unneeded data for ( let i = 0, l = textures.length; i < l; i ++ ) { const texture = textures[ i ]; if ( texture.image instanceof ImageBitmap ) { texture.image.close(); } texture.dispose(); } return; } cached.materials = materials; cached.geometry = geometry; cached.textures = textures; cached.scene = scene; cached.metadata = metadata; } disposeTile( tile ) { super.disposeTile( tile ); // This could get called before the tile has finished downloading const cached = tile.cached; if ( cached.scene ) { const materials = cached.materials; const geometry = cached.geometry; const textures = cached.textures; 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 ) { child.userData.meshFeatures.dispose(); } if ( child.userData.structuralMetadata ) { child.userData.structuralMetadata.dispose(); } } ); for ( let i = 0, l = geometry.length; i < l; i ++ ) { geometry[ i ].dispose(); } for ( let i = 0, l = materials.length; i < l; i ++ ) { materials[ i ].dispose(); } for ( let i = 0, l = textures.length; i < l; i ++ ) { const texture = textures[ i ]; if ( texture.image instanceof ImageBitmap ) { texture.image.close(); } texture.dispose(); } if ( parent ) { parent.remove( cached.scene ); } this.dispatchEvent( { type: 'dispose-model', scene: cached.scene, tile, } ); cached.scene = null; cached.materials = null; cached.textures = null; cached.geometry = null; cached.metadata = null; } } setTileVisible( tile, visible ) { const scene = tile.cached.scene; const group = this.group; if ( visible ) { if ( scene ) { group.add( scene ); scene.updateMatrixWorld( true ); } } else { if ( scene ) { group.remove( scene ); } } super.setTileVisible( tile, visible ); this.dispatchEvent( { type: 'tile-visibility-change', scene, tile, visible, } ); } calculateBytesUsed( tile, scene ) { const bytesUsed = this._bytesUsed; if ( ! bytesUsed.has( tile ) && scene ) { bytesUsed.set( tile, estimateBytesUsed( scene ) ); } return bytesUsed.get( tile ) ?? null; } calculateTileViewError( tile, target ) { const cached = tile.cached; const cameras = this.cameras; const cameraInfo = this.cameraInfo; const boundingVolume = cached.boundingVolume; let inView = false; let inViewError = - Infinity; let inViewDistance = Infinity; let maxError = - Infinity; let minDistance = Infinity; for ( let i = 0, l = cameras.length; i < l; i ++ ) { // calculate the camera error const info = cameraInfo[ i ]; let error; let distance; if ( info.isOrthographic ) { const pixelSize = info.pixelSize; error = tile.geometricError / pixelSize; distance = Infinity; } else { // avoid dividing 0 by 0 which can result in NaN. If the distance to the tile is // 0 then the error should be infinity. const sseDenominator = info.sseDenominator; distance = boundingVolume.distanceToPoint( info.position ); error = distance === 0 ? Infinity : tile.geometricError / ( distance * sseDenominator ); } // Track which camera frustums this tile is in so we can use it // to ignore the error calculations for cameras that can't see it const frustum = cameraInfo[ i ].frustum; if ( boundingVolume.intersectsFrustum( frustum ) ) { inView = true; inViewError = Math.max( inViewError, error ); inViewDistance = Math.min( inViewDistance, distance ); } maxError = Math.max( maxError, error ); minDistance = Math.min( minDistance, distance ); } // check the plugin visibility this.invokeAllPlugins( plugin => { if ( plugin !== this && plugin.calculateTileViewError && plugin.calculateTileViewError( tile, viewErrorTarget ) ) { // Tile shall be traversed if inView for at least one plugin. inView = inView && viewErrorTarget.inView; maxError = Math.max( maxError, viewErrorTarget.error ); if ( viewErrorTarget.inView ) { inViewError = Math.max( inViewError, viewErrorTarget.error ); } } } ); // If the tiles are out of view then use the global distance and error calculated if ( inView ) { target.inView = true; target.error = inViewError; target.distanceFromCamera = inViewDistance; } else { target.inView = viewErrorTarget.inView; target.error = maxError; target.distanceFromCamera = minDistance; } } // adjust the rotation of the group such that Y is altitude, X is North, and Z is East setLatLonToYUp( lat, lon ) { console.warn( 'TilesRenderer: setLatLonToYUp is deprecated. Use the ReorientationPlugin, instead.' ); const { ellipsoid, group } = this; _euler.set( Math.PI / 2, Math.PI / 2, 0 ); _mat.makeRotationFromEuler( _euler ); ellipsoid.getEastNorthUpFrame( lat, lon, 0, group.matrix ) .multiply( _mat ) .invert() .decompose( group.position, group.quaternion, group.scale, ); group.updateMatrixWorld( true ); } dispose() { super.dispose(); this.group.removeFromParent(); } }