UNPKG

3d-tiles-renderer

Version:

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

463 lines (321 loc) 12.4 kB
import { Vector3 } from 'three'; import { QuantizedMeshLoader } from './loaders/QuantizedMeshLoader.js'; import { TilingScheme } from './images/utils/TilingScheme.js'; import { ProjectionScheme } from './images/utils/ProjectionScheme.js'; import { QuantizedMeshClipper } from './utilities/QuantizedMeshClipper.js'; const TILE_X = Symbol( 'TILE_X' ); const TILE_Y = Symbol( 'TILE_Y' ); const TILE_LEVEL = Symbol( 'TILE_LEVEL' ); const TILE_AVAILABLE = Symbol( 'TILE_AVAILABLE' ); // We don't know the height ranges for the tile set on load so assume a large range and // adjust it once the tiles have actually loaded based on the min and max height const INITIAL_HEIGHT_RANGE = 1e4; const _vec = /* @__PURE__ */ new Vector3(); // Checks if the given tile is available function isTileAvailable( available, level, x, y ) { if ( available && level < available.length ) { // TODO: consider a binary search const availableSet = available[ level ]; for ( let i = 0, l = availableSet.length; i < l; i ++ ) { const { startX, startY, endX, endY } = availableSet[ i ]; if ( x >= startX && x <= endX && y >= startY && y <= endY ) { return true; } } } return false; } // Calculates the max level that can be loaded. function getMaxLevel( layer ) { const { available = null, maxzoom = null } = layer; return maxzoom === null ? available.length - 1 : maxzoom; } // Calculates whether metadata availability is present - returns -1 if not. function getMetadataAvailability( layer ) { const { metadataAvailability = - 1 } = layer; return metadataAvailability; } // Calculates whether the given tile should have metadata availability function getTileHasMetadata( tile, layer ) { const level = tile[ TILE_LEVEL ]; const metadataAvailability = getMetadataAvailability( layer ); const maxLevel = getMaxLevel( layer ); return level < maxLevel && metadataAvailability !== - 1 && ( level % metadataAvailability ) === 0; } // Constructs the url for the given tile content function getContentUrl( x, y, level, version, layer ) { return layer.tiles[ 0 ] .replace( /{\s*z\s*}/g, level ) .replace( /{\s*x\s*}/g, x ) .replace( /{\s*y\s*}/g, y ) .replace( /{\s*version\s*}/g, version ); } export class QuantizedMeshPlugin { constructor( options = {} ) { const { useRecommendedSettings = true, skirtLength = null, smoothSkirtNormals = true, solid = false, } = options; // plugin needs to run before other plugins that fetch data since content // is handled and loaded in a custom way this.name = 'QUANTIZED_MESH_PLUGIN'; this.priority = - 1000; this.tiles = null; this.layer = null; this.useRecommendedSettings = useRecommendedSettings; this.skirtLength = skirtLength; this.smoothSkirtNormals = smoothSkirtNormals; this.solid = solid; this.attribution = null; this.tiling = new TilingScheme(); this.projection = new ProjectionScheme(); } // Plugin function init( tiles ) { // TODO: should we avoid setting this globally? tiles.fetchOptions.headers = tiles.fetchOptions.headers || {}; tiles.fetchOptions.headers.Accept = 'application/vnd.quantized-mesh,application/octet-stream;q=0.9'; if ( this.useRecommendedSettings ) { tiles.errorTarget = 2; } this.tiles = tiles; } loadRootTileSet() { const { tiles } = this; // initialize href to resolve the root in case it's specified as a relative url let url = new URL( 'layer.json', new URL( tiles.rootURL, location.href ) ); tiles.invokeAllPlugins( plugin => url = plugin.preprocessURL ? plugin.preprocessURL( url, null ) : url ); return tiles .invokeOnePlugin( plugin => plugin.fetchData && plugin.fetchData( url, this.tiles.fetchOptions ) ) .then( res => res.json() ) .then( json => { this.layer = json; const { projection: layerProjection = 'EPSG:4326', extensions = [], attribution = '', available = null, } = json; const { tiling, tiles, projection, } = this; // attribution if ( attribution ) { this.attribution = { value: attribution, type: 'string', collapsible: true, }; } // extensions if ( extensions.length > 0 ) { tiles.fetchOptions.headers[ 'Accept' ] += `;extensions=${ extensions.join( '-' ) }`; } // initialize tiling, projection projection.setScheme( layerProjection ); const { tileCountX, tileCountY } = projection; tiling.setProjection( projection ); tiling.generateLevels( getMaxLevel( json ) + 1, tileCountX, tileCountY ); // initialize children const children = []; for ( let x = 0; x < tileCountX; x ++ ) { const child = this.createChild( 0, x, 0, available ); if ( child ) { children.push( child ); } } // produce the tile set root const tileset = { asset: { version: '1.1' }, geometricError: Infinity, root: { refine: 'REPLACE', geometricError: Infinity, boundingVolume: { region: [ ...this.tiling.getContentBounds(), - INITIAL_HEIGHT_RANGE, INITIAL_HEIGHT_RANGE ], }, children: children, [ TILE_AVAILABLE ]: available, [ TILE_LEVEL ]: - 1, }, }; let baseUrl = tiles.rootURL; tiles.invokeAllPlugins( plugin => baseUrl = plugin.preprocessURL ? plugin.preprocessURL( baseUrl, null ) : baseUrl ); tiles.preprocessTileSet( tileset, baseUrl ); return tileset; } ); } parseToMesh( buffer, tile, extension, uri ) { const { skirtLength, solid, smoothSkirtNormals, tiles, } = this; // set up loader const ellipsoid = tiles.ellipsoid; // split the parent tile if needed let result; if ( extension === 'quantized_tile_split' ) { // split the parent tile const searchParams = new URL( uri ).searchParams; const left = searchParams.get( 'left' ) === 'true'; const bottom = searchParams.get( 'bottom' ) === 'true'; // parse the tile data const clipper = new QuantizedMeshClipper(); clipper.ellipsoid.copy( ellipsoid ); clipper.solid = solid; clipper.smoothSkirtNormals = smoothSkirtNormals; clipper.skirtLength = skirtLength === null ? tile.geometricError : skirtLength; const [ west, south, east, north ] = tile.parent.boundingVolume.region; clipper.minLat = south; clipper.maxLat = north; clipper.minLon = west; clipper.maxLon = east; result = clipper.clipToQuadrant( tile.parent.cached.scene, left, bottom ); } else if ( extension === 'terrain' ) { const loader = new QuantizedMeshLoader( tiles.manager ); loader.ellipsoid.copy( ellipsoid ); loader.solid = solid; loader.smoothSkirtNormals = smoothSkirtNormals; loader.skirtLength = skirtLength === null ? tile.geometricError : skirtLength; const [ west, south, east, north ] = tile.boundingVolume.region; loader.minLat = south; loader.maxLat = north; loader.minLon = west; loader.maxLon = east; result = loader.parse( buffer ); } else { return; } // adjust the bounding region to be more accurate based on the contents of the terrain file // NOTE: The debug region bounds are only created after the tile is first shown so the debug // region bounding volume will have the correct dimensions. const { minHeight, maxHeight, metadata } = result.userData; tile.boundingVolume.region[ 4 ] = minHeight; tile.boundingVolume.region[ 5 ] = maxHeight; tile.cached.boundingVolume.setRegionData( ellipsoid, ...tile.boundingVolume.region ); // use the geometric error value if it's present if ( metadata ) { if ( 'geometricerror' in metadata ) { tile.geometricError = metadata.geometricerror; } // if the tile hasn't been expanded yet and isn't in the queue to do so then // mark it for expansion again const hasMetadata = getTileHasMetadata( tile, this.layer ); if ( hasMetadata && 'available' in metadata && tile.children.length === 0 ) { // add an offset to account for the current and previous layers tile[ TILE_AVAILABLE ] = [ ...new Array( tile[ TILE_LEVEL ] + 1 ).fill( null ), ...metadata.available, ]; } } // NOTE: we expand children only once the parent mesh data is loaded to ensure the mesh // data is ready for clipping. It's possible that this child data gets to the parse stage // first, otherwise, while the parent is still downloading. // Ideally we would be able to guarantee parents are loaded first but this is an odd case. this.expandChildren( tile ); return result; } getAttributions( target ) { if ( this.attribution ) { target.push( this.attribution ); } } // Local functions createChild( level, x, y, available ) { const { tiles, layer, tiling, projection } = this; const ellipsoid = tiles.ellipsoid; // metadata availability will return "null" if there are no more children but we // have to always load the root tile data. const isAvailable = available === null && level === 0 || isTileAvailable( available, level, x, y ); const url = getContentUrl( x, y, level, 1, layer ); const region = [ ...tiling.getTileBounds( x, y, level ), - INITIAL_HEIGHT_RANGE, INITIAL_HEIGHT_RANGE ]; const [ /* west */, south, /* east */, north, /* minHeight */, maxHeight ] = region; const midLat = ( south > 0 ) !== ( north > 0 ) ? 0 : Math.min( Math.abs( south ), Math.abs( north ) ); // get the projected perimeter ellipsoid.getCartographicToPosition( midLat, 0, maxHeight, _vec ); _vec.z = 0; // https://github.com/CesiumGS/cesium/blob/53889cbed2a91d38e0fae4b6f2dcf6783632fc92/packages/engine/Source/Scene/QuadtreeTileProvider.js#L24-L31 // Implicit quantized mesh tile error halves with every layer const tileCountX = projection.tileCountX; const maxRadius = Math.max( ...ellipsoid.radius ); const rootGeometricError = maxRadius * 2 * Math.PI * 0.25 / ( 65 * tileCountX ); const geometricError = rootGeometricError / ( 2 ** level ); // Create the child const tile = { [ TILE_AVAILABLE ]: null, [ TILE_LEVEL ]: level, [ TILE_X ]: x, [ TILE_Y ]: y, refine: 'REPLACE', geometricError: geometricError, boundingVolume: { region }, content: isAvailable ? { uri: url } : null, children: [] }; // if we're relying on tile metadata availability then skip storing the tile metadata if ( ! getTileHasMetadata( tile, layer ) ) { tile[ TILE_AVAILABLE ] = available; } return tile; } expandChildren( tile ) { const level = tile[ TILE_LEVEL ]; const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; const available = tile[ TILE_AVAILABLE ]; // only expand down to the highest level if ( level >= this.tiling.maxLevel ) { return; } let hasChildren = false; for ( let cx = 0; cx < 2; cx ++ ) { for ( let cy = 0; cy < 2; cy ++ ) { const child = this.createChild( level + 1, 2 * x + cx, 2 * y + cy, available ); if ( child.content !== null ) { tile.children.push( child ); hasChildren = true; } else { tile.children.push( child ); child.content = { uri: `tile.quantized_tile_split?bottom=${ cy === 0 }&left=${ cx === 0 }` }; } } } if ( ! hasChildren ) { tile.children.length = 0; } } fetchData( uri, options ) { // if this is our custom url indicating a tile split then return fake response if ( /quantized_tile_split/.test( uri ) ) { return new ArrayBuffer(); } } disposeTile( tile ) { // dispose of the available array since we will get it again if this tile is loaded if ( getTileHasMetadata( tile, this.layer ) ) { tile[ TILE_AVAILABLE ] = null; } // Note: we remove all children always because child tiles can rely on splitting parent tiles // and we can find ourselves in a situation where a child tile is ready first but the parent tile // hasn't loaded, causing a stall / race condition in the parsing queue. To avoid this dependency // we just remove all children and generate them again one the parent is loaded. // Only get rid of the children if this plugin was responsible for them. if ( TILE_AVAILABLE in tile ) { tile.children.forEach( child => { // TODO: there should be a reliable way for removing children like this. this.tiles.processNodeQueue.remove( child ); } ); tile.children.length = 0; tile.__childrenProcessed = 0; } } }