UNPKG

3d-tiles-renderer

Version:

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

1,085 lines (710 loc) 25.8 kB
/** @import { Camera, WebGLRenderer, Box3, Sphere, Raycaster } from 'three' */ /** @import { Ellipsoid } from '../math/Ellipsoid.js' */ 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, LoadingManager, EventDispatcher, Group, } from 'three'; import { raycastTraverse } 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/addons/loaders/GLTFLoader.js'; // 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 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; } ); } /** * Three.js implementation of a 3D Tiles renderer. Extends `TilesRendererBase` with * camera management, three.js scene integration, and GPU-accelerated tile loading. * Add `tiles.group` to your scene and call `tiles.update()` each frame. * @extends TilesRendererBase */ export class TilesRenderer extends TilesRendererBase { /** * If `true`, all tile meshes automatically have `frustumCulled` set to `false` since the * tiles renderer performs its own frustum culling. If `displayActiveTiles` is `true` or * multiple cameras are being used, consider setting this to `false`. * @type {boolean} * @default true */ get autoDisableRendererCulling() { return this._autoDisableRendererCulling; } set autoDisableRendererCulling( value ) { if ( this._autoDisableRendererCulling !== value ) { super._autoDisableRendererCulling = value; this.forEachLoadedModel( ( scene ) => { updateFrustumCulled( scene, ! value ); } ); } } constructor( ...args ) { super( ...args ); /** * Whether to use the bounding-volume hierarchy to accelerate raycasting. When disabled, * all active tile geometry is tested directly. Useful for tilesets with inaccurate * bounding volumes (e.g. Google Photorealistic Tiles) where traversal may miss * geometry between bounding volumes. * @type {boolean} * @default true */ this.accelerateRaycast = true; /** * The container `Group` for the 3D tiles. Add this to the three.js scene. The group * also exposes a `matrixWorldInverse` field for transforming objects into the local * tileset frame. * @type {Group} */ this.group = new TilesGroup( this ); /** * The ellipsoid definition used for the tileset. May be overridden by the * `3DTILES_ellipsoid` extension. Specified in the local frame of `TilesRenderer.group`. * @type {Ellipsoid} * @default WGS84_ELLIPSOID */ this.ellipsoid = WGS84_ELLIPSOID.clone(); /** * Array of cameras registered with this renderer. * @type {Camera[]} */ this.cameras = []; this.cameraMap = new Map(); this.cameraInfo = []; this._upRotationMatrix = new Matrix4(); this._bytesUsed = new WeakMap(); // flag indicating whether frustum culling should be disabled this._autoDisableRendererCulling = true; /** * The `LoadingManager` used when loading tile geometry. * @type {LoadingManager} * @default new LoadingManager() */ this.manager = new LoadingManager(); // saved for event dispatcher functions this._listeners = {}; } addEventListener( type, listener ) { EventDispatcher.prototype.addEventListener.call( this, type, listener ); } hasEventListener( type, listener ) { return EventDispatcher.prototype.hasEventListener.call( this, type, listener ); } removeEventListener( type, listener ) { EventDispatcher.prototype.removeEventListener.call( this, type, listener ); } dispatchEvent( e ) { EventDispatcher.prototype.dispatchEvent.call( this, e ); } /* Public API */ /** * Returns the axis-aligned bounding box of the root tile in the group's local space. * @param {Box3} target - Target box to write into. * @returns {boolean} Whether the tileset is loaded and a bounding box is available. */ getBoundingBox( target ) { if ( ! this.root ) { return false; } const boundingVolume = this.root.engineData.boundingVolume; if ( boundingVolume ) { boundingVolume.getAABB( target ); return true; } else { return false; } } /** * Returns the oriented bounding box and transform of the root tile. * @param {Box3} targetBox - Target box to write into (in local OBB space). * @param {Matrix4} targetMatrix - Transform from OBB local space to group local space. * @returns {boolean} Whether the tileset is loaded and an OBB is available. */ getOrientedBoundingBox( targetBox, targetMatrix ) { if ( ! this.root ) { return false; } const boundingVolume = this.root.engineData.boundingVolume; if ( boundingVolume ) { boundingVolume.getOBB( targetBox, targetMatrix ); return true; } else { return false; } } /** * Returns the bounding sphere of the root tile in the group's local space. * @param {Sphere} target - Target sphere to write into. * @returns {boolean} Whether the tileset is loaded and a bounding sphere is available. */ getBoundingSphere( target ) { if ( ! this.root ) { return false; } const boundingVolume = this.root.engineData.boundingVolume; if ( boundingVolume ) { boundingVolume.getSphere( target ); return true; } else { return false; } } /** * Iterates over all currently loaded tile scenes. * @param {Function} callback - Called with `( scene: Object3D, tile: object )` for each loaded tile. */ forEachLoadedModel( callback ) { this.traverse( tile => { const scene = tile.engineData && tile.engineData.scene; if ( scene ) { callback( scene, tile ); } }, null, false ); } /** * Performs a raycast against all loaded tile scenes. Compatible with Three.js raycasting. * Supports `raycaster.firstHitOnly` for early termination. * @param {Raycaster} raycaster * @param {Array} intersects - Array to push intersection results into. */ raycast( raycaster, intersects ) { if ( ! this.root ) { return; } if ( this.accelerateRaycast ) { raycastTraverse( this, this.root, raycaster, intersects ); } else { const hits = raycaster.firstHitOnly ? [] : intersects; for ( const tile of this.activeTiles ) { const { scene } = tile.engineData; if ( ! this.invokeOnePlugin( plugin => { return plugin.raycastTile && plugin.raycastTile( tile, scene, raycaster, hits ); } ) ) { raycaster.intersectObject( scene, true, hits ); } } if ( raycaster.firstHitOnly && hits.length > 0 ) { hits.sort( ( a, b ) => a.distance - b.distance ); intersects.push( hits[ 0 ] ); } } } /** * Returns whether the given camera is registered with this renderer. * @param {Camera} camera * @returns {boolean} */ hasCamera( camera ) { return this.cameraMap.has( camera ); } /** * Registers a camera with the renderer so it is used for tile selection and screen-space error * calculation. Use `setResolution` or `setResolutionFromRenderer` to provide the camera's resolution. * @param {Camera} camera * @returns {boolean} Whether the camera was newly added. */ 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; } /** * Sets the render resolution for a registered camera, used for screen-space error calculation. * @param {Camera} camera - A previously registered camera. * @param {number|Vector2} xOrVec - Render width in pixels, or a Vector2 containing width and height. * @param {number} [y] - Render height in pixels when `xOrVec` is a number. * @returns {boolean} Whether the camera is registered and the resolution was updated. */ 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; } /** * Returns the render resolution previously set for a registered camera. * @param {Camera} camera - A previously registered camera. * @param {Vector2} target - Vector2 to write the result into. * @returns {Vector2|null} The target with width/height filled in, or null if the camera is not registered. */ getResolution( camera, target ) { const vec = this.cameraMap.get( camera ); if ( ! vec ) return null; return target.copy( vec ); } /** * Sets the render resolution for a camera by reading the current size from a WebGLRenderer. * @param {Camera} camera - A previously registered camera. * @param {WebGLRenderer} renderer * @returns {boolean} Whether the camera is registered and the resolution was updated. */ setResolutionFromRenderer( camera, renderer ) { renderer.getSize( tempVector2 ); return this.setResolution( camera, tempVector2.x, tempVector2.y ); } /** * Unregisters a camera from the renderer. * @param {Camera} camera * @returns {boolean} Whether the camera was found and removed. */ 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; } ); } prepareForTraversal() { 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, camera.coordinateSystem, camera.reversedDepth ); // get transform position in group root frame position.set( 0, 0, 0 ); position.applyMatrix4( camera.matrixWorld ); position.applyMatrix4( group.matrixWorldInverse ); } } update() { super.update(); // check for cameras _after_ base update so we can enable pre-loading the root tileset if ( this.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.engineData.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 ); } // Extend the base engineData structure with Three.js-specific fields // Base class initializes: scene, metadata, boundingVolume tile.engineData.transform = transform; tile.engineData.transformInverse = transformInverse; tile.engineData.boundingVolume = boundingVolume; tile.engineData.geometry = null; tile.engineData.materials = null; tile.engineData.textures = null; } async parseTile( buffer, tile, extension, url, abortSignal ) { const engineData = tile.engineData; const workingPath = LoaderUtils.getWorkingPath( url ); const fetchOptions = this.fetchOptions; const manager = this.manager; let promise = null; const tileTransform = engineData.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, url, 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( tileTransform ); 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; } engineData.materials = materials; engineData.geometry = geometry; engineData.textures = textures; engineData.scene = scene; engineData.metadata = metadata; } disposeTile( tile ) { // TODO: call this "disposeTileModel"? super.disposeTile( tile ); // This could get called before the tile has finished downloading const engineData = tile.engineData; if ( engineData.scene ) { const materials = engineData.materials; const geometry = engineData.geometry; const textures = engineData.textures; const parent = engineData.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? engineData.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( engineData.scene ); } engineData.scene = null; engineData.materials = null; engineData.textures = null; engineData.geometry = null; engineData.metadata = null; } } setTileActive( tile, active ) { const scene = tile.engineData.scene; const group = this.group; if ( scene ) { // update matrices for the tile scene scene.traverse( c => { c.updateMatrix(); c.matrixWorld.copy( c.matrix ); if ( c.parent ) { c.matrixWorld.premultiply( c.parent.matrixWorld ); } else { c.matrixWorld.premultiply( group.matrixWorld ); } } ); } super.setTileActive( tile, active ); } setTileVisible( tile, visible ) { const scene = tile.engineData.scene; const group = this.group; if ( visible ) { if ( scene ) { group.add( scene ); } } else { if ( scene ) { group.remove( scene ); } } super.setTileVisible( 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 engineData = tile.engineData; const cameras = this.cameras; const cameraInfo = this.cameraInfo; const boundingVolume = engineData.boundingVolume; let inView = false; let inViewError = 0; let inViewDistance = Infinity; let maxCameraError = 0; let minCameraDistance = 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 ); } maxCameraError = Math.max( maxCameraError, error ); minCameraDistance = Math.min( minCameraDistance, distance ); } if ( inView ) { // write the in-camera error and distance parameters target.inView = true; target.error = inViewError; target.distanceFromCamera = inViewDistance; } else { // otherwise write variables for load priority target.inView = false; target.error = maxCameraError; target.distanceFromCamera = minCameraDistance; } } dispose() { super.dispose(); this.group.removeFromParent(); } }