3d-tiles-renderer
Version:
https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification
1,847 lines (1,153 loc) • 40.4 kB
JavaScript
import { WebGLRenderTarget, Color, SRGBColorSpace, BufferAttribute, Matrix4, Vector3, Box3, Triangle, CanvasTexture } from 'three';
import { PriorityQueue, PriorityQueueItemRemovedError } from '3d-tiles-renderer/core';
import { CesiumIonAuth, GoogleCloudAuth } from '3d-tiles-renderer/core/plugins';
import { TiledTextureComposer } from './overlays/TiledTextureComposer.js';
import { XYZImageSource } from './sources/XYZImageSource.js';
import { QuadKeyImageSource } from './sources/QuadKeyImageSource.js';
import { TMSImageSource } from './sources/TMSImageSource.js';
import { forEachTileInBounds, getMeshesCartographicRange, getMeshesPlanarRange } from './overlays/utils.js';
import { wrapOverlaysMaterial } from './overlays/wrapOverlaysMaterial.js';
import { GeometryClipper } from '../utilities/GeometryClipper.js';
import { WMTSImageSource } from './sources/WMTSImageSource.js';
import { MemoryUtils } from '3d-tiles-renderer/three';
import { GeoJSONImageSource } from './sources/GeoJSONImageSource.js';
import { WMSImageSource } from './sources/WMSImageSource.js';
const _matrix = /* @__PURE__ */ new Matrix4();
const _vec = /* @__PURE__ */ new Vector3();
const _center = /* @__PURE__ */ new Vector3();
const _sphereCenter = /* @__PURE__ */ new Vector3();
const _normal = /* @__PURE__ */ new Vector3();
const _box = /* @__PURE__ */ new Box3();
const SPLIT_TILE_DATA = Symbol( 'SPLIT_TILE_DATA' );
const SPLIT_HASH = Symbol( 'SPLIT_HASH' );
// function for marking and releasing images in the given overlay
function markOverlayImages( range, level, overlay, doRelease ) {
// return null immediately if possible to allow for drawing without delay where possible
if ( Array.isArray( overlay ) ) {
const promises = overlay
.map( o => markOverlayImages( range, level, o, doRelease ) )
.filter( p => p !== null );
if ( promises.length === 0 ) {
return null;
} else {
return Promise.all( promises );
}
}
if ( ! overlay.isReady ) {
return overlay.whenReady().then( markImages );
} else {
return markImages();
}
function markImages() {
const promises = [];
const { imageSource, tiling } = overlay;
forEachTileInBounds( range, level, tiling, ( tx, ty, tl ) => {
if ( doRelease ) {
imageSource.release( tx, ty, tl );
} else {
promises.push( imageSource.lock( tx, ty, tl ) );
}
} );
const filteredPromises = promises.filter( p => p instanceof Promise );
if ( filteredPromises.length !== 0 ) {
return Promise.all( filteredPromises );
} else {
return null;
}
}
}
// returns the total number of tiles that will be drawn for the provided range
function countTilesInRange( range, level, overlay ) {
let total = 0;
forEachTileInBounds( range, level, overlay.tiling, ( x, y, l ) => {
total ++;
} );
return total;
}
// Plugin for overlaying tiled image data on top of 3d tiles geometry.
export class ImageOverlayPlugin {
get enableTileSplitting() {
return this._enableTileSplitting;
}
set enableTileSplitting( v ) {
if ( this._enableTileSplitting !== v ) {
this._enableTileSplitting = v;
this._markNeedsUpdate();
}
}
constructor( options = {} ) {
const {
overlays = [],
resolution = 256,
renderer = null,
enableTileSplitting = true,
} = options;
// plugin needs to run before other plugins that fetch data since content
// is handled and loaded in a custom way
this.name = 'IMAGE_OVERLAY_PLUGIN';
this.priority = - 15;
// options
this.renderer = renderer;
this.resolution = resolution;
this._enableTileSplitting = enableTileSplitting;
this.overlays = [];
// internal
this.needsUpdate = false;
this.tiles = null;
this.tileComposer = null;
this.tileControllers = new Map();
this.overlayInfo = new Map();
this.usedTextures = new Set();
this.meshParams = new WeakMap();
this.pendingTiles = new Map();
this.processedTiles = new Set();
this.processQueue = null;
this._onUpdateAfter = null;
this._onTileDownloadStart = null;
this._cleanupScheduled = false;
this._virtualChildResetId = 0;
this._bytesUsed = new WeakMap();
overlays.forEach( overlay => {
this.addOverlay( overlay );
} );
}
// plugin functions
init( tiles ) {
if ( ! this.renderer ) {
throw new Error( 'ImageOverlayPlugin: "renderer" instance must be provided.' );
}
const tileComposer = new TiledTextureComposer( this.renderer );
const processQueue = new PriorityQueue();
processQueue.maxJobs = 10;
processQueue.priorityCallback = ( a, b ) => {
const tileA = a.tile;
const tileB = b.tile;
const visibleA = tiles.visibleTiles.has( tileA );
const visibleB = tiles.visibleTiles.has( tileB );
if ( visibleA !== visibleB ) {
// load visible tiles first
return visibleA ? 1 : - 1;
} else {
// the fallback to the download queue tile priority
return tiles.downloadQueue.priorityCallback( tileA, tileB );
}
// TODO: we could prioritize by overlay order here to ensure consistency
};
// save variables
this.tiles = tiles;
this.tileComposer = tileComposer;
this.processQueue = processQueue;
// init all existing tiles
tiles.forEachLoadedModel( ( scene, tile ) => {
this._processTileModel( scene, tile, true );
} );
// update callback for when overlays have changed
this._onUpdateAfter = async () => {
// check if the projection changed for any of the overlays and refresh them
let overlayChanged = false;
this.overlayInfo.forEach( ( info, overlay ) => {
if (
Boolean( overlay.frame ) !== Boolean( info.frame ) ||
overlay.frame && info.frame && ! info.frame.equals( overlay.frame )
) {
const order = info.order;
this.deleteOverlay( overlay );
this.addOverlay( overlay, order );
overlayChanged = true;
}
} );
// trigger redraws for visible tiles if overlays updated
if ( overlayChanged ) {
const maxJobs = processQueue.maxJobs;
let count = 0;
processQueue.items.forEach( info => {
if ( tiles.visibleTiles.has( info.tile ) ) {
count ++;
}
} );
processQueue.maxJobs = count + processQueue.currJobs;
processQueue.tryRunJobs();
processQueue.maxJobs = maxJobs;
this.needsUpdate = true;
}
// update all the layer uvs
if ( this.needsUpdate ) {
this.needsUpdate = false;
const { overlays, overlayInfo } = this;
overlays.sort( ( a, b ) => {
return overlayInfo.get( a ).order - overlayInfo.get( b ).order;
} );
tiles.forEachLoadedModel( ( scene, tile ) => {
this._updateLayers( tile );
} );
this.resetVirtualChildren( ! this.enableTileSplitting );
tiles.recalculateBytesUsed();
tiles.dispatchEvent( { type: 'needs-rerender' } );
}
};
this._onTileDownloadStart = ( { tile, url } ) => {
// TODO: it's not super straight forward to detect whether a tile is "geometry" or not ahead of time. Checking
// for "subtree" or "json" are good broad strokes but some cases will still be missed.
if ( ! /\.json$/i.test( url ) && ! /\.subtree/i.test( url ) ) {
this.processedTiles.add( tile );
this._initTileOverlayInfo( tile );
}
};
tiles.addEventListener( 'update-after', this._onUpdateAfter );
tiles.addEventListener( 'tile-download-start', this._onTileDownloadStart );
this.overlays.forEach( overlay => {
this._initOverlay( overlay );
} );
}
disposeTile( tile ) {
const { overlayInfo, tileControllers, processQueue, pendingTiles, processedTiles } = this;
processedTiles.delete( tile );
// Cancel any ongoing tasks. If a tile is cancelled while downloading
// this will not have been created, yet.
if ( tileControllers.has( tile ) ) {
tileControllers.get( tile ).abort();
tileControllers.delete( tile );
pendingTiles.delete( tile );
}
// stop any tile loads
overlayInfo.forEach( ( ( { tileInfo }, overlay ) => {
if ( tileInfo.has( tile ) ) {
const { meshInfo, range, meshRange, level, target, meshRangeMarked, rangeMarked } = tileInfo.get( tile );
// release the ranges
if ( meshRange !== null && meshRangeMarked ) {
markOverlayImages( meshRange, level, overlay, true );
}
if ( range !== null && rangeMarked ) {
markOverlayImages( range, level, overlay, true );
}
if ( target !== null ) {
// release the render targets
target.dispose();
}
tileInfo.delete( tile );
meshInfo.clear();
}
} ) );
// Remove any items that reference the tile being disposed
processQueue.removeByFilter( item => {
return item.tile === tile;
} );
}
calculateBytesUsed( tile ) {
const { overlayInfo } = this;
const bytesUsed = this._bytesUsed;
let bytes = null;
overlayInfo.forEach( ( { tileInfo }, overlay ) => {
if ( tileInfo.has( tile ) ) {
const { target } = tileInfo.get( tile );
bytes = bytes || 0;
bytes += MemoryUtils.getTextureByteLength( target?.texture );
}
} );
if ( bytes !== null ) {
bytesUsed.set( tile, bytes );
return bytes;
} else if ( bytesUsed.has( tile ) ) {
return bytesUsed.get( tile );
} else {
return 0;
}
}
processTileModel( scene, tile ) {
return this._processTileModel( scene, tile );
}
async _processTileModel( scene, tile, initialization = false ) {
const { tileControllers, processedTiles, pendingTiles } = this;
tileControllers.set( tile, new AbortController() );
if ( ! initialization ) {
// we save all these pending tiles so that they can be correctly initialized if an
// overlay is added in the time between when this function starts and after the async
// await call. Otherwise the tile could be missed. But if we're initializing the plugin
// then we don't need to do this because the tiles are already included in the traversal.
pendingTiles.set( tile, scene );
}
// track which tiles we have been processed and remove them in "disposeTile"
processedTiles.add( tile );
this._wrapMaterials( scene );
this._initTileOverlayInfo( tile );
await this._initTileSceneOverlayInfo( scene, tile );
this.expandVirtualChildren( scene, tile );
this._updateLayers( tile );
pendingTiles.delete( tile );
}
dispose() {
const { tileComposer, tiles } = this;
// dispose textures
tileComposer.dispose();
// dispose of all overlays
const overlays = [ ...this.overlays ];
overlays.forEach( overlay => {
this.deleteOverlay( overlay );
} );
// reset the textures of the meshes
tiles.forEachLoadedModel( ( scene, tile ) => {
this._updateLayers( tile );
this.disposeTile( tile );
delete tile[ SPLIT_HASH ];
} );
tiles.removeEventListener( 'update-after', this._onUpdateAfter );
this.resetVirtualChildren( true );
}
getAttributions( target ) {
this.overlays.forEach( overlay => {
if ( overlay.opacity > 0 ) {
overlay.getAttributions( target );
}
} );
}
parseToMesh( buffer, tile, extension, uri ) {
if ( extension === 'image_overlay_tile_split' ) {
return tile[ SPLIT_TILE_DATA ];
}
}
async resetVirtualChildren( fullDispose = false ) {
// only run this if all the overlays are ready and tile targets have been generated, etc
// so we can make an effort to only remove the necessary tiles.
this._virtualChildResetId ++;
const id = this._virtualChildResetId;
await Promise.all( this.overlays.map( o => o.whenReady() ) );
if ( id !== this._virtualChildResetId ) {
return;
}
// collect the tiles split into virtual tiles
const { tiles } = this;
const parents = new Set();
tiles.forEachLoadedModel( ( scene, tile ) => {
if ( SPLIT_HASH in tile ) {
parents.add( tile );
}
} );
// dispose of the virtual children if this tile would not be split or the spilt could change
// under the current overlays used.
parents.forEach( parent => {
if ( parent.parent === null ) {
return;
}
const clone = parent.cached.scene.clone();
clone.updateMatrixWorld();
const { hash } = this._getSplitVectors( clone, parent );
if ( parent[ SPLIT_HASH ] !== hash || fullDispose ) {
// TODO: if are parent tile is forcibly remove then we should make sure that all the children are, too?
const children = collectChildren( parent );
children.sort( ( a, b ) => ( b.__depth || 0 ) - ( a.__depth || 0 ) );
// note that we need to remove children from the processing queue in this case
// because we are forcibly evicting them from the cache.
children.forEach( child => {
tiles.processNodeQueue.remove( child );
tiles.lruCache.remove( child );
child.parent = null;
} );
parent.children.length = 0;
parent.__childrenProcessed = 0;
}
} );
// re-expand tiles if needed
if ( ! fullDispose ) {
tiles.forEachLoadedModel( ( scene, tile ) => {
this.expandVirtualChildren( scene, tile );
} );
}
function collectChildren( root, target = [] ) {
root.children.forEach( child => {
target.push( child );
collectChildren( child, target );
} );
return target;
}
}
_getSplitVectors( scene, tile, centerTarget = _center ) {
const { tiles, overlayInfo } = this;
// get the center of the content
const box = new Box3();
box.setFromObject( scene );
box.getCenter( centerTarget );
// find the vectors that are orthogonal to every overlay projection
const splitDirections = [];
const hashTokens = [];
overlayInfo.forEach( ( { tileInfo }, overlay ) => {
// if the tile has a render target associated with the overlay and the last level of detail
// is not being displayed, yet, then we need to split
const info = tileInfo.get( tile );
if ( info && info.target && overlay.tiling.maxLevel > info.level ) {
// get the vector representing the projection direction
if ( overlay.frame ) {
_normal.set( 0, 0, 1 ).transformDirection( overlay.frame );
} else {
tiles.ellipsoid.getPositionToNormal( centerTarget, _normal );
if ( _normal.length() < 1e-6 ) {
_normal.set( 1, 0, 0 );
}
}
// dedupe vectors in the hash
const token = `${ _normal.x.toFixed( 3 ) },${ _normal.y.toFixed( 3 ) },${ _normal.z.toFixed( 3 ) }_`;
if ( ! hashTokens.includes( token ) ) {
hashTokens.push( token );
}
// construct the orthogonal vectors
const other = _vec.set( 0, 0, 1 );
if ( Math.abs( _normal.dot( other ) ) > 1 - 1e-4 ) {
other.set( 1, 0, 0 );
}
const ortho0 = new Vector3().crossVectors( _normal, other ).normalize();
const ortho1 = new Vector3().crossVectors( _normal, ortho0 ).normalize();
splitDirections.push( ortho0, ortho1 );
}
} );
// Generate a reduced set of vectors by averages directions in a 45 degree cone so
// we don't split unnecessarily
const directions = [];
while ( splitDirections.length !== 0 ) {
const normalized = splitDirections.pop().clone();
const average = normalized.clone();
for ( let i = 0; i < splitDirections.length; i ++ ) {
const dir = splitDirections[ i ];
const dotProduct = normalized.dot( dir );
if ( Math.abs( dotProduct ) > Math.cos( Math.PI / 8 ) ) {
average.addScaledVector( dir, Math.sign( dotProduct ) );
normalized.copy( average ).normalize();
splitDirections.splice( i, 1 );
i --;
}
}
directions.push( average.normalize() );
}
return { directions, hash: hashTokens.join( '' ) };
}
async expandVirtualChildren( scene, tile ) {
if ( tile.children.length !== 0 || this.enableTileSplitting === false ) {
return;
}
// create a copy of the content to transform and split
const clone = scene.clone();
clone.updateMatrixWorld();
// get the directions to split on
const { directions, hash } = this._getSplitVectors( clone, tile, _center );
tile[ SPLIT_HASH ] = hash;
// if there are no directions to split on then exit early
if ( directions.length === 0 ) {
return;
}
// set up the splitter to ignore overlay uvs
const clipper = new GeometryClipper();
clipper.attributeList = key => ! /^layer_uv_\d+/.test( key );
directions.map( splitDirection => {
clipper.addSplitOperation( ( geometry, i0, i1, i2, barycoord, matrixWorld ) => {
Triangle.getInterpolatedAttribute( geometry.attributes.position, i0, i1, i2, barycoord, _vec );
return _vec.applyMatrix4( matrixWorld ).sub( _center ).dot( splitDirection );
} );
} );
// run the clipping operations by performing every permutation of sides
// defined by the split directions
const children = [];
clipper.forEachSplitPermutation( () => {
// clip the object itself
const result = clipper.clipObject( clone );
// remove the parent transform because it will be multiplied back in after the fact
result.matrix
.premultiply( tile.cached.transformInverse )
.decompose( result.position, result.quaternion, result.scale );
// collect the meshes
const meshes = [];
result.traverse( c => {
if ( c.isMesh ) {
const material = c.material.clone();
c.material = material;
for ( const key in material ) {
const value = material[ key ];
if ( value && value.isTexture ) {
if ( value.source.data instanceof ImageBitmap ) {
// clone any image bitmap textures using canvas because if we share the texture then when
// the clipped child is disposed then it will dispose of the parent tile texture data, as well.
const canvas = document.createElement( 'canvas' );
canvas.width = value.image.width;
canvas.height = value.image.height;
const ctx = canvas.getContext( '2d' );
ctx.scale( 1, - 1 );
ctx.drawImage( value.source.data, 0, 0, canvas.width, - canvas.height );
const tex = new CanvasTexture( canvas );
tex.mapping = value.mapping;
tex.wrapS = value.wrapS;
tex.wrapT = value.wrapT;
tex.minFilter = value.minFilter;
tex.magFilter = value.magFilter;
tex.format = value.format;
tex.type = value.type;
tex.anisotropy = value.anisotropy;
tex.colorSpace = value.colorSpace;
tex.generateMipmaps = value.generateMipmaps;
material[ key ] = tex;
}
}
}
meshes.push( c );
}
} );
if ( meshes.length === 0 ) {
return;
}
// generate a region bounding volume
const boundingVolume = {};
if ( tile.boundingVolume.region ) {
boundingVolume.region = getMeshesCartographicRange( meshes, this.tiles.ellipsoid ).region;
}
// create a sphere bounding volume
if ( tile.boundingVolume.box || tile.boundingVolume.sphere ) {
// TODO: we create a sphere even when a region is present because currently the handling of region volumes
// is a bit flaky especially at small scales. OBBs are generated which can be imperfect resulting rays passing
// through tiles. The same may be the case with frustum checks. In theory, though, we should not need a sphere
// bounds if a region bounds are present.
// compute the sphere center
_box
.setFromObject( result, true )
.getCenter( _sphereCenter );
// calculate the sq radius from all vertices
let maxSqRadius = 0;
result.traverse( c => {
const geometry = c.geometry;
if ( geometry ) {
const position = geometry.attributes.position;
for ( let i = 0, l = position.count; i < l; i ++ ) {
const sqRadius = _vec
.fromBufferAttribute( position, i )
.applyMatrix4( c.matrixWorld )
.distanceToSquared( _sphereCenter );
maxSqRadius = Math.max( maxSqRadius, sqRadius );
}
}
} );
boundingVolume.sphere = [ ..._sphereCenter, Math.sqrt( maxSqRadius ) ];
}
children.push( {
refine: 'REPLACE',
geometricError: tile.geometricError * 0.5,
boundingVolume: boundingVolume,
content: { uri: './child.image_overlay_tile_split' },
children: [],
[ SPLIT_TILE_DATA ]: result,
} );
} );
// force the tile "refine" mode to be set to "REPLACE" if we're splitting tiles
// TODO: If a tile is of type "ADD" refine and it has children then it will not be split
// as expected since only geometry tiles with no children are split. Instead we'd want
// to split this tiles geometry in addition to adding the child tiles.
tile.refine = 'REPLACE';
tile.children.push( ...children );
}
fetchData( uri, options ) {
// if this is our custom url indicating a tile split then return fake response
if ( /image_overlay_tile_split/.test( uri ) ) {
return new ArrayBuffer();
}
}
// public
addOverlay( overlay, order = null ) {
const { tiles, overlays, overlayInfo } = this;
if ( order === null ) {
// set the order to the next largest order value
order = overlays.reduce( ( v, o ) => Math.max( v, o.order + 1 ), 0 );
}
const controller = new AbortController();
overlays.push( overlay );
overlayInfo.set( overlay, {
order: order,
uniforms: {},
tileInfo: new Map(),
controller: controller,
frame: overlay.frame ? overlay.frame.clone() : null,
} );
if ( tiles !== null ) {
this._initOverlay( overlay );
}
}
setOverlayOrder( overlay, order ) {
const index = this.overlays.indexOf( overlay );
if ( index !== - 1 ) {
this.overlayInfo.get( overlay ).order = order;
this._markNeedsUpdate();
}
}
deleteOverlay( overlay ) {
const { overlays, overlayInfo, processQueue, processedTiles } = this;
const index = overlays.indexOf( overlay );
if ( index !== - 1 ) {
// delete tile info explicitly instead of blindly dispose of the full overlay
const { tileInfo, controller } = overlayInfo.get( overlay );
processedTiles.forEach( tile => {
if ( ! tileInfo.has( tile ) ) {
// check for the case where tiles have been added but not properly initialized with the
// given overlay, yet
return;
}
const {
meshInfo,
range,
meshRange,
level,
target,
meshRangeMarked,
rangeMarked,
} = tileInfo.get( tile );
// release the ranges
if ( meshRange !== null && meshRangeMarked ) {
markOverlayImages( meshRange, level, overlay, true );
}
if ( range !== null && rangeMarked ) {
markOverlayImages( range, level, overlay, true );
}
if ( target !== null ) {
// release the render targets
target.dispose();
}
tileInfo.delete( tile );
meshInfo.clear();
} );
tileInfo.clear();
overlayInfo.delete( overlay );
controller.abort();
// Remove any items that reference the overlay being disposed
processQueue.removeByFilter( item => {
return item.overlay === overlay;
} );
overlays.splice( index, 1 );
this._markNeedsUpdate();
}
}
// internal
_calculateLevelFromOverlay( overlay, range, tile ) {
if ( overlay.isPlanarProjection ) {
const { resolution } = this;
const { tiling } = overlay;
const [ minX, minY, maxX, maxY ] = range;
const w = maxX - minX;
const h = maxY - minY;
let level = 0;
const { maxLevel } = tiling;
for ( ; level < maxLevel; level ++ ) {
// the number of pixels per image on each axis
const wProj = resolution / w;
const hProj = resolution / h;
const { pixelWidth, pixelHeight } = tiling.getLevel( level );
if ( pixelWidth >= wProj || pixelHeight >= hProj ) {
break;
}
}
// TODO: should this be one layer higher LoD?
return level;
} else {
return tile.__depthFromRenderedParent - 1;
}
}
// initialize the overlay to use the right fetch options, load all data for existing tiles
_initOverlay( overlay ) {
const { tiles } = this;
if ( ! overlay.isInitialized ) {
overlay.init();
overlay.whenReady().then( () => {
overlay.imageSource.fetchData = ( ...args ) => tiles
.downloadQueue
.add( { priority: - performance.now() }, () => {
return overlay.fetch( ...args );
} );
} );
}
const promises = [];
const initTile = async ( scene, tile ) => {
this._initTileOverlayInfo( tile, overlay );
const promise = this._initTileSceneOverlayInfo( scene, tile, overlay );
promises.push( promise );
// mark tiles as needing an update after initialized so we get a trickle in of tiles
await promise;
this._updateLayers( tile );
};
tiles.forEachLoadedModel( initTile );
this.pendingTiles.forEach( ( scene, tile ) => {
initTile( scene, tile );
} );
Promise.all( promises ).then( () => {
this._markNeedsUpdate();
} );
}
// wrap all materials in the given scene wit the overlay material shader
_wrapMaterials( scene ) {
scene.traverse( c => {
if ( c.material ) {
const params = wrapOverlaysMaterial( c.material, c.material.onBeforeCompile );
this.meshParams.set( c, params );
}
} );
}
// Initialize per-tile overlay information. This function triggers an async function but
// does not need to be awaited for use since it's just locking textures which are awaited later.
_initTileOverlayInfo( tile, overlay = this.overlays ) {
if ( Array.isArray( overlay ) ) {
overlay.forEach( o => this._initTileOverlayInfo( tile, o ) );
return;
}
// This function is resilient to multiple calls in case an overlay is added after a tile starts loading
// and before it is loaded, meaning this function needs to be called twice to ensure it's initialized.
const { overlayInfo, processQueue } = this;
if ( overlayInfo.get( overlay ).tileInfo.has( tile ) ) {
return;
}
const info = {
range: null,
meshRange: null,
level: null,
target: null,
meshInfo: new Map(),
rangeMarked: false,
meshRangeMarked: false,
};
overlayInfo
.get( overlay )
.tileInfo
.set( tile, info );
// if the overlay isn't ready then we can't convert the range correctly, yet
if ( overlay.isReady ) {
if ( overlay.isPlanarProjection ) {
// TODO: we could project the shape into the frame, compute 2d bounds, and then mark tiles
} else if ( tile.boundingVolume.region ) {
// If the tile has a region bounding volume then mark the tiles to preload
const [ minLon, minLat, maxLon, maxLat ] = tile.boundingVolume.region;
const range = overlay.tiling.toNormalizedRange( [ minLon, minLat, maxLon, maxLat ] );
info.range = range;
info.level = this._calculateLevelFromOverlay( overlay, range, tile );
processQueue
.add( { tile, overlay }, () => {
info.rangeMarked = true;
return markOverlayImages( range, info.level, overlay, false );
} )
.catch( err => {
if ( ! ( err instanceof PriorityQueueItemRemovedError ) ) {
throw err;
}
} );
}
}
}
// initialize the scene meshes
async _initTileSceneOverlayInfo( scene, tile, overlay = this.overlays ) {
if ( Array.isArray( overlay ) ) {
return Promise.all( overlay.map( o => this._initTileSceneOverlayInfo( scene, tile, o ) ) );
}
const { tiles, overlayInfo, resolution, tileComposer, tileControllers, usedTextures, processQueue } = this;
const { ellipsoid } = tiles;
const { controller, tileInfo } = overlayInfo.get( overlay );
const tileController = tileControllers.get( tile );
// wait for the overlay to be completely loaded so projection and tiling are available
if ( ! overlay.isReady ) {
await overlay.whenReady();
}
// check if the overlay or tile have been disposed since starting this function
// if the tileController is not present then the tile has been disposed of already
if ( controller.signal.aborted || tileController.signal.aborted ) {
return;
}
// find all meshes to project on and ensure matrices are up to date
const meshes = [];
scene.updateMatrixWorld();
scene.traverse( c => {
if ( c.isMesh ) {
meshes.push( c );
}
} );
const { tiling, imageSource } = overlay;
const info = tileInfo.get( tile );
let range, uvs, heightInRange;
// retrieve the uvs and range for all the meshes
if ( overlay.isPlanarProjection ) {
_matrix.copy( overlay.frame );
if ( scene.parent !== null ) {
_matrix.multiply( tiles.group.matrixWorldInverse );
}
let heightRange;
( { range, uvs, heightRange } = getMeshesPlanarRange( meshes, _matrix, tiling ) );
heightInRange = ! ( heightRange[ 0 ] > 1 || heightRange[ 1 ] < 0 );
} else {
_matrix.identity();
if ( scene.parent !== null ) {
_matrix.copy( tiles.group.matrixWorldInverse );
}
( { range, uvs } = getMeshesCartographicRange( meshes, ellipsoid, _matrix, tiling ) );
range = tiling.toNormalizedRange( range );
heightInRange = true;
}
// calculate the tiling level here if not already created
if ( info.level === null ) {
info.level = this._calculateLevelFromOverlay( overlay, range, tile );
}
// if the image projection is outside the 0, 1 uvw range or there are no textures to draw in
// the tiled image set the don't allocate a texture for it.
let target = null;
if ( heightInRange && countTilesInRange( range, info.level, overlay ) !== 0 ) {
target = new WebGLRenderTarget( resolution, resolution, {
depthBuffer: false,
stencilBuffer: false,
generateMipmaps: false,
colorSpace: SRGBColorSpace,
} );
}
info.meshRange = range;
info.target = target;
meshes.forEach( ( mesh, i ) => {
const array = new Float32Array( uvs[ i ] );
const attribute = new BufferAttribute( array, 3 );
info.meshInfo.set( mesh, { attribute } );
} );
if ( target !== null ) {
await processQueue
.add( { tile, overlay }, async () => {
info.meshRangeMarked = true;
const promise = markOverlayImages( range, info.level, overlay, false );
if ( promise ) {
// if the previous layer is present then draw it as an overlay to fill in any gaps while we wait for
// the next set of textures
tileComposer.setRenderTarget( target, range );
tileComposer.clear( 0xffffff, 0 );
forEachTileInBounds( range, info.level - 1, tiling, ( tx, ty, tl ) => {
// draw using normalized bounds since the mercator bounds are non-linear
const span = tiling.getTileBounds( tx, ty, tl, true, false );
const tex = imageSource.get( tx, ty, tl );
if ( tex && ! ( tex instanceof Promise ) ) {
tileComposer.draw( tex, span );
usedTextures.add( tex );
this._scheduleCleanup();
}
} );
try {
await promise;
} catch {
// skip errors since this will throw when aborted
return;
}
}
// check if the overlay has been disposed since starting this function
if ( controller.signal.aborted || tileController.signal.aborted ) {
return;
}
// draw the textures
tileComposer.setRenderTarget( target, range );
tileComposer.clear( 0xffffff, 0 );
forEachTileInBounds( range, info.level, tiling, ( tx, ty, tl ) => {
// draw using normalized bounds since the mercator bounds are non-linear
const span = tiling.getTileBounds( tx, ty, tl, true, false );
const tex = imageSource.get( tx, ty, tl );
tileComposer.draw( tex, span );
usedTextures.add( tex );
this._scheduleCleanup();
} );
} )
.catch( err => {
if ( ! ( err instanceof PriorityQueueItemRemovedError ) ) {
throw err;
}
} );
}
}
_updateLayers( tile ) {
const { overlayInfo, overlays, tileControllers } = this;
const tileController = tileControllers.get( tile );
// by this point all targets should be present and we can force the memory to update
this.tiles.recalculateBytesUsed( tile );
// if the tile has been disposed before this function is called then exit early
if ( ! tileController || tileController.signal.aborted ) {
return;
}
// update the uvs and texture overlays for each mesh
overlays.forEach( ( overlay, i ) => {
const { tileInfo } = overlayInfo.get( overlay );
const { meshInfo, target } = tileInfo.get( tile );
meshInfo.forEach( ( { attribute }, mesh ) => {
const { geometry, material } = mesh;
const params = this.meshParams.get( mesh );
// assign the new uvs
const key = `layer_uv_${ i }`;
if ( geometry.getAttribute( key ) !== attribute ) {
geometry.setAttribute( key, attribute );
geometry.dispose();
}
// set the uniform array lengths
params.layerMaps.length = overlays.length;
params.layerInfo.length = overlays.length;
// assign the uniforms
params.layerMaps.value[ i ] = target !== null ? target.texture : null;
params.layerInfo.value[ i ] = overlay;
// mark per-layer defines
material.defines[ `LAYER_${ i }_EXISTS` ] = Number( target !== null );
material.defines[ `LAYER_${ i }_ALPHA_INVERT` ] = Number( overlay.alphaInvert );
material.defines[ `LAYER_${ i }_ALPHA_MASK` ] = Number( overlay.alphaMask );
material.defines.LAYER_COUNT = overlays.length;
material.needsUpdate = true;
} );
} );
}
_scheduleCleanup() {
// clean up textures used for drawing the tile overlays
if ( ! this._cleanupScheduled ) {
this._cleanupScheduled = true;
requestAnimationFrame( () => {
const { usedTextures } = this;
usedTextures.forEach( tex => {
tex.dispose();
} );
usedTextures.clear();
this._cleanupScheduled = false;
} );
}
}
_markNeedsUpdate() {
if ( this.needsUpdate === false ) {
this.needsUpdate = true;
if ( this.tiles !== null ) {
this.tiles.dispatchEvent( { type: 'needs-update' } );
}
}
}
}
class ImageOverlay {
get tiling() {
return this.imageSource.tiling;
}
get projection() {
return this.tiling.projection;
}
get isPlanarProjection() {
return Boolean( this.frame );
}
get aspectRatio() {
return this.tiling && this.isReady ? this.tiling.aspectRatio : 1;
}
get fetchOptions() {
return this.imageSource.fetchOptions;
}
set fetchOptions( v ) {
this.imageSource.fetchOptions = v;
}
constructor( options = {} ) {
const {
opacity = 1,
color = 0xffffff,
frame = null,
preprocessURL = null,
alphaMask = false,
alphaInvert = false,
} = options;
this.imageSource = null;
this.preprocessURL = preprocessURL;
this.opacity = opacity;
this.color = new Color( color );
this.frame = frame !== null ? frame.clone() : null;
this.alphaMask = alphaMask;
this.alphaInvert = alphaInvert;
this.isReady = false;
this.isInitialized = false;
}
init() {
this.isInitialized = true;
this.whenReady().then( () => {
this.isReady = true;
} );
}
fetch( url, options = {} ) {
if ( this.preprocessURL ) {
url = this.preprocessURL( url );
}
return fetch( url, options );
}
whenReady() {
}
getAttributions( target ) {
}
dispose() {
this.imageSource.dispose();
}
}
export class XYZTilesOverlay extends ImageOverlay {
constructor( options = {} ) {
super( options );
this.imageSource = new XYZImageSource( options );
this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
}
init() {
this._whenReady = this.imageSource.init();
super.init();
}
whenReady() {
return this._whenReady;
}
}
export class GeoJSONOverlay extends ImageOverlay {
constructor( options = {} ) {
super( options );
this.imageSource = new GeoJSONImageSource( options );
this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
}
init() {
this._whenReady = this.imageSource.init();
super.init();
}
whenReady() {
return this._whenReady;
}
}
export class WMSTilesOverlay extends ImageOverlay {
constructor( options = {} ) {
super( options );
this.imageSource = new WMSImageSource( options );
this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
}
init() {
this._whenReady = this.imageSource.init();
super.init();
}
whenReady() {
return this._whenReady;
}
}
export class WMTSTilesOverlay extends ImageOverlay {
constructor( options = {} ) {
super( options );
this.imageSource = new WMTSImageSource( options );
this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
}
init() {
this._whenReady = this.imageSource.init();
super.init();
}
whenReady() {
return this._whenReady;
}
}
export class TMSTilesOverlay extends ImageOverlay {
constructor( options = {} ) {
super( options );
this.imageSource = new TMSImageSource( options );
this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
this.url = options.url;
}
init() {
this._whenReady = this.imageSource.init();
super.init();
}
whenReady() {
return this._whenReady;
}
}
export class CesiumIonOverlay extends ImageOverlay {
constructor( options = {} ) {
super( options );
const { apiToken, autoRefreshToken, assetId } = options;
this.options = options;
this.assetId = assetId;
this.auth = new CesiumIonAuth( { apiToken, autoRefreshToken } );
this.auth.authURL = `https://api.cesium.com/v1/assets/${ assetId }/endpoint`;
this._attributions = [];
this.externalType = false;
}
init() {
this._whenReady = this
.auth
.refreshToken()
.then( async ( json ) => {
this._attributions = json.attributions.map( att => ( {
value: att.html,
type: 'html',
collapsible: att.collapsible,
} ) );
if ( json.type !== 'IMAGERY' ) {
throw new Error( 'CesiumIonOverlay: Only IMAGERY is supported as overlay type.' );
}
this.externalType = Boolean( json.externalType );
switch ( json.externalType ) {
case 'GOOGLE_2D_MAPS': {
const { url, session, key, tileWidth } = json.options;
const xyzUrl = `${ url }/v1/2dtiles/{z}/{x}/{y}?session=${ session }&key=${ key }`;
this.imageSource = new XYZImageSource( {
...this.options,
url: xyzUrl,
tileDimension: tileWidth,
// Google maps tiles have a fixed depth of 22
// https://developers.google.com/maps/documentation/tile/2d-tiles-overview
levels: 22,
} );
break;
}
case 'BING': {
const { url, mapStyle, key } = json.options;
const metadataUrl = `${ url }/REST/v1/Imagery/Metadata/${ mapStyle }?incl=ImageryProviders&key=${ key }&uriScheme=https`;
const response = await fetch( metadataUrl ).then( res => res.json() );
const metadata = response.resourceSets[ 0 ].resources[ 0 ];
this.imageSource = new QuadKeyImageSource( {
...this.options,
url: metadata.imageUrl,
subdomains: metadata.imageUrlSubdomains,
tileDimension: metadata.tileWidth,
levels: metadata.zoomMax,
} );
break;
}
default:
this.imageSource = new TMSImageSource( {
...this.options,
url: json.url,
} );
}
this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
return this.imageSource.init();
} );
super.init();
}
fetch( ...args ) {
// bypass auth fetch if asset is external type to prevent CORS error due to wrong bearer token
return this.externalType ? super.fetch( ...args ) : this.auth.fetch( ...args );
}
whenReady() {
return this._whenReady;
}
getAttributions( target ) {
target.push( ...this._attributions );
}
}
export class GoogleMapsOverlay extends ImageOverlay {
constructor( options = {} ) {
super( options );
const { apiToken, sessionOptions, autoRefreshToken, logoUrl } = options;
this.logoUrl = logoUrl;
this.auth = new GoogleCloudAuth( { apiToken, sessionOptions, autoRefreshToken } );
this.imageSource = new XYZImageSource();
this.imageSource.fetchData = ( ...args ) => this.fetch( ...args );
this._logoAttribution = {
value: '',
type: 'image',
collapsible: false,
};
}
init() {
this._whenReady = this
.auth
.refreshToken()
.then( json => {
this.imageSource.tileDimension = json.tileWidth;
this.imageSource.url = 'https://tile.googleapis.com/v1/2dtiles/{z}/{x}/{y}';
return this.imageSource.init();
} );
super.init();
}
fetch( ...args ) {
return this.auth.fetch( ...args );
}
whenReady() {
return this._whenReady;
}
getAttributions( target ) {
if ( this.logoUrl ) {
this._logoAttribution.value = this.logoUrl;
target.push( this._logoAttribution );
}
}
}