UNPKG

3d-tiles-renderer

Version:

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

672 lines (451 loc) 17.4 kB
/** @import { ImageOverlay } from './ImageOverlayPlugin.js' */ import { Mesh, MeshBasicMaterial, PlaneGeometry, MathUtils, Vector3, Sphere } from 'three'; export const TILE_X = Symbol( 'TILE_X' ); export const TILE_Y = Symbol( 'TILE_Y' ); export const TILE_LEVEL = Symbol( 'TILE_LEVEL' ); import { getCartographicToMeterDerivative } from './utils/getCartographicToMeterDerivative.js'; import { TilingScheme } from './utils/TilingScheme.js'; import { ProjectionScheme } from './utils/ProjectionScheme.js'; const MIN_LON_VERTS = 30; const MIN_LAT_VERTS = 15; const DEFAULT_LEVELS = 20; const OVERLAY_RANGE = Symbol( 'OVERLAY_RANGE' ); const OVERLAY_LEVEL = Symbol( 'OVERLAY_LEVEL' ); const _pos = /* @__PURE__ */ new Vector3(); const _norm = /* @__PURE__ */ new Vector3(); const _sphere = /* @__PURE__ */ new Sphere(); /** * Plugin that generates tiled surface geometry from a tiling scheme, optionally loading * image overlay data. * * The tiling scheme and projection are derived from a provided overlay. * If the source's projection is cartographic (any EPSG scheme), the plugin supports * both planar and ellipsoidal geometry via the `shape` option. * * @param {Object} [options] * @param {ImageOverlay} [options.overlay=null] Overlay instance to derive the tiling scheme from. When `applyOverlayTexture` is enabled, also used to texture the generated tile meshes. * @param {string} [options.shape='ellipsoid'] Geometry shape: `'planar'` or `'ellipsoid'`. Only * meaningful for cartographic sources. * @param {boolean} [options.endCaps=true] For Mercator ellipsoid mode, snap poles to ±90° lat. * @param {boolean} [options.center=true] Shift planar tiles so the image is centered at origin. * @param {boolean} [options.useRecommendedSettings=true] Apply recommended TilesRenderer settings. * @param {boolean} [options.applyOverlayTexture=false] Whether to apply the overlay's texture to the generated tile meshes. */ export class GeneratedSurfacePlugin { constructor( options = {} ) { const { overlay = null, shape = 'ellipsoid', endCaps = true, center = true, useRecommendedSettings = true, applyOverlayTexture = false, } = options; this.priority = - 10; this.tiles = null; this.overlay = overlay; this.shape = shape; this.endCaps = endCaps; this.center = center; this.useRecommendedSettings = useRecommendedSettings; this.applyOverlayTexture = applyOverlayTexture; this._tiling = null; } // Plugin functions init( tiles ) { if ( this.useRecommendedSettings ) { tiles.errorTarget = 1; } this.tiles = tiles; } async loadRootTileset() { const { overlay } = this; if ( overlay ) { await overlay.init(); this._tiling = overlay.tiling || this._createDefaultTiling(); } else { this._tiling = this._createDefaultTiling(); } return this.getTileset(); } async parseToMesh( buffer, tile, extension, url, abortSignal ) { if ( extension !== 'generated_surface' ) { return null; } let res; if ( this._useEllipsoid() ) { res = this._createEllipsoidMesh( tile ); } else { res = this._createPlanarMesh( tile ); } const { overlay, applyOverlayTexture } = this; if ( overlay && applyOverlayTexture ) { const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; const level = tile[ TILE_LEVEL ]; const range = this._tiling.getTileBounds( x, y, level, true, false ); if ( overlay.hasContent( range, level ) ) { try { await overlay.lockTexture( range, level ); } catch ( err ) { if ( err.name !== 'AbortError' ) { throw err; } return null; } const texture = overlay.getTexture( range, level ); tile[ OVERLAY_RANGE ] = range; tile[ OVERLAY_LEVEL ] = level; if ( abortSignal.aborted ) { overlay.releaseTexture( range, level ); delete tile[ OVERLAY_RANGE ]; delete tile[ OVERLAY_LEVEL ]; return null; } res.material.map = texture; res.material.needsUpdate = true; } } return res; } preprocessNode( tile ) { const tiling = this._tiling; const maxLevel = tiling.maxLevel; const level = tile[ TILE_LEVEL ]; if ( level < maxLevel && tile.parent !== null ) { this.expandChildren( tile ); } } disposeTile( tile ) { const range = tile[ OVERLAY_RANGE ]; if ( this.overlay && range ) { this.overlay.releaseTexture( range, tile[ OVERLAY_LEVEL ] ); delete tile[ OVERLAY_RANGE ]; delete tile[ OVERLAY_LEVEL ]; } } dispose() { this.tiles.forEachLoadedModel( ( scene, tile ) => { this.disposeTile( tile ); } ); } /** * Returns the cartographic coordinates for a given world-space position. "lat" and "lon" are assigned * to the target object. * @param {Vector3} position - World-space position. For ellipsoid surfaces this is a * 3D point on the surface; for planar surfaces it is a 2D point in the plane. * @param {{ lat: number, lon: number }} [target={}] - Optional target object to write results into. * @returns {{ lat: number, lon: number }} The cartographic coordinates in radians. * @throws {Error} If the tiling projection is not cartographic. */ getCartographicFromPosition( position, target = {} ) { const { _tiling: tiling } = this; const { projection } = tiling; if ( ! projection.isCartographic ) { throw new Error( 'GeneratedSurfacePlugin: getCartographicFromPosition requires a cartographic projection.' ); } if ( this._useEllipsoid() ) { return this.tiles.ellipsoid.getPositionToCartographic( position, target ); } const { center } = this; const normX = position.x / tiling.aspectRatio + ( center ? 0.5 : 0 ); const normY = position.y + ( center ? 0.5 : 0 ); target.lat = projection.convertNormalizedToLatitude( normY ); target.lon = projection.convertNormalizedToLongitude( normX ); return target; } /** * Returns the world-space position for a given cartographic coordinate. * @param {number} lat - Latitude in radians. * @param {number} lon - Longitude in radians. * @param {Vector3} [target=new Vector3()] - Optional target Vector3 to write results into. * @returns {Vector3} The world-space position. For planar surfaces z is set to 0. * @throws {Error} If the tiling projection is not cartographic. */ getPositionFromCartographic( lat, lon, target = new Vector3() ) { const { _tiling: tiling } = this; const { projection } = tiling; if ( ! projection.isCartographic ) { throw new Error( 'GeneratedSurfacePlugin: getPositionFromCartographic requires a cartographic projection.' ); } if ( this._useEllipsoid() ) { return this.tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, target ); } const { center } = this; const normX = projection.convertLongitudeToNormalized( lon ); const normY = projection.convertLatitudeToNormalized( lat ); target.x = ( normX - ( center ? 0.5 : 0 ) ) * tiling.aspectRatio; target.y = normY - ( center ? 0.5 : 0 ); target.z = 0; return target; } // whether the plugin is loading as an ellipsoid or not _useEllipsoid() { return this._tiling.projection.isCartographic && this.shape === 'ellipsoid'; } _createPlanarMesh( tile ) { const tx = tile[ TILE_X ]; const ty = tile[ TILE_Y ]; const level = tile[ TILE_LEVEL ]; const boundingBox = tile.boundingVolume.box; let sx = 1, sy = 1, x = 0, y = 0, z = 0; if ( boundingBox ) { [ x, y, z ] = boundingBox; sx = boundingBox[ 3 ]; sy = boundingBox[ 7 ]; } // adjust the geometry transform itself rather than the mesh because it reduces the artifact errors // when rendering. const geometry = new PlaneGeometry( 2 * sx, 2 * sy ); const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.set( x, y, z ); // adjust the uvs so only the relevant texture portion is visible const uvRange = this._tiling.getTileContentUVBounds( tx, ty, level ); const { uv } = geometry.attributes; for ( let i = 0; i < uv.count; i ++ ) { uv.setXY( i, MathUtils.mapLinear( uv.getX( i ), 0, 1, uvRange[ 0 ], uvRange[ 2 ] ), MathUtils.mapLinear( uv.getY( i ), 0, 1, uvRange[ 1 ], uvRange[ 3 ] ), ); } return mesh; } _createEllipsoidMesh( tile ) { const { tiles, endCaps, _tiling: tiling } = this; const { projection } = tiling; const level = tile[ TILE_LEVEL ]; const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; // new geometry // default to a minimum number of vertices per degree on each axis const [ west, south, east, north ] = tile.boundingVolume.region; const latVerts = Math.max( MIN_LAT_VERTS, Math.ceil( ( north - south ) * MathUtils.RAD2DEG * 0.25 ) ); const lonVerts = Math.max( MIN_LON_VERTS, Math.ceil( ( east - west ) * MathUtils.RAD2DEG * 0.25 ) ); const cols = lonVerts + 3; const rows = latVerts + 3; const geometry = new PlaneGeometry( 1, 1, lonVerts + 2, latVerts + 2 ); const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true, true ); const uvRange = tiling.getTileContentUVBounds( x, y, level ); // adjust the geometry to position it at the region const { position, normal, uv } = geometry.attributes; const vertCount = position.count; tile.engineData.boundingVolume.getSphere( _sphere ); for ( let i = 0; i < vertCount; i ++ ) { // determine whether this vertex is part of the skirt or not const col = i % cols; const row = Math.floor( i / cols ); const isSkirt = col === 0 || col === cols - 1 || row === 0 || row === rows - 1; const innerCol = Math.max( 1, Math.min( cols - 2, col ) ); const innerRow = Math.max( 1, Math.min( rows - 2, row ) ); const uNorm = ( innerCol - 1 ) / lonVerts; const vNorm = 1 - ( innerRow - 1 ) / latVerts; // convert the plane position to lat / lon const lon = projection.convertNormalizedToLongitude( MathUtils.mapLinear( uNorm, 0, 1, minU, maxU ) ); let lat = projection.convertNormalizedToLatitude( MathUtils.mapLinear( vNorm, 0, 1, minV, maxV ) ); // snap edges to poles for Mercator to avoid seams if ( projection.isMercator && endCaps ) { if ( maxV === 1 && vNorm === 1 ) { lat = Math.PI / 2; } if ( minV === 0 && vNorm === 0 ) { lat = - Math.PI / 2; } } // ensure we have an edge loop positioned at the mercator limit to avoid UV distortion // as much as possible at low LoDs. if ( projection.isMercator && vNorm !== 0 && vNorm !== 1 ) { const latLimit = projection.convertNormalizedToLatitude( 1 ); const vStep = 1 / latVerts; const prevLat = MathUtils.mapLinear( vNorm - vStep, 0, 1, south, north ); const nextLat = MathUtils.mapLinear( vNorm + vStep, 0, 1, south, north ); if ( lat > latLimit && prevLat < latLimit ) { lat = latLimit; } if ( lat < - latLimit && nextLat > - latLimit ) { lat = - latLimit; } } // get the position and normal tiles.ellipsoid.getCartographicToPosition( lat, lon, 0, _pos ).sub( _sphere.center ); tiles.ellipsoid.getCartographicToNormal( lat, lon, _norm ); if ( isSkirt ) { _pos.addScaledVector( _norm, - tile.geometricError ); } // derive UV from the final (potentially adjusted) lat/lon so the overlay samples correctly const u = MathUtils.mapLinear( projection.convertLongitudeToNormalized( lon ), minU, maxU, uvRange[ 0 ], uvRange[ 2 ] ); const v = MathUtils.mapLinear( projection.convertLatitudeToNormalized( lat ), minV, maxV, uvRange[ 1 ], uvRange[ 3 ] ); // update the geometry position.setXYZ( i, _pos.x, _pos.y, _pos.z ); normal.setXYZ( i, _norm.x, _norm.y, _norm.z ); uv.setXY( i, u, v ); } const mesh = new Mesh( geometry, new MeshBasicMaterial() ); mesh.position.copy( _sphere.center ); return mesh; } getTileset() { const { tiles, _tiling: tiling } = this; const minLevel = tiling.minLevel; const { tileCountX, tileCountY } = tiling.getLevel( minLevel ); const children = []; for ( let x = 0; x < tileCountX; x ++ ) { for ( let y = 0; y < tileCountY; y ++ ) { const child = this.createChild( x, y, minLevel ); if ( child !== null ) { children.push( child ); } } } // generate tileset const tileset = { asset: { version: '1.1' }, geometricError: Infinity, root: { refine: 'REPLACE', geometricError: Infinity, boundingVolume: this.createBoundingVolume( 0, 0, - 1 ), children, [ TILE_LEVEL ]: - 1, [ TILE_X ]: 0, [ TILE_Y ]: 0, }, }; tiles.preprocessTileset( tileset, '' ); return tileset; } getUrl( /* x, y, level */ ) { return 'tile.generated_surface'; } fetchData( url ) { if ( /generated_surface/.test( url ) ) { return new ArrayBuffer(); } } createBoundingVolume( x, y, level, regionHeight = 0 ) { const { _tiling: tiling } = this; const isRoot = level === - 1; if ( this._useEllipsoid() ) { const { endCaps } = this; let normalizedBounds; let cartBounds; if ( isRoot ) { normalizedBounds = tiling.getContentBounds( true ); cartBounds = tiling.getContentBounds(); } else { normalizedBounds = tiling.getTileBounds( x, y, level, true, true ); cartBounds = tiling.getTileBounds( x, y, level, false, true ); } if ( endCaps ) { if ( normalizedBounds[ 3 ] === 1 ) cartBounds[ 3 ] = Math.PI / 2; if ( normalizedBounds[ 1 ] === 0 ) cartBounds[ 1 ] = - Math.PI / 2; } return { region: [ ...cartBounds, - regionHeight, 1 ] }; } else { const { center } = this; let normalizedBounds; if ( isRoot ) { normalizedBounds = tiling.getContentBounds( true ); } else { normalizedBounds = tiling.getTileBounds( x, y, level, true ); } // calculate the world space bounds position from the range const [ minX, minY, maxX, maxY ] = normalizedBounds; let extentsX = ( maxX - minX ) / 2; let extentsY = ( maxY - minY ) / 2; let centerX = minX + extentsX; let centerY = minY + extentsY; if ( center ) { centerX -= 0.5; centerY -= 0.5; } // scale the fields centerX *= tiling.aspectRatio; extentsX *= tiling.aspectRatio; // return bounding box return { box: [ // center centerX, centerY, 0, // x, y, z half extents extentsX, 0.0, 0.0, 0.0, extentsY, 0.0, 0.0, 0.0, 0.0, ], }; } } createChild( x, y, level ) { const { _tiling: tiling } = this; const { projection } = tiling; if ( ! tiling.getTileExists( x, y, level ) ) { return null; } let geometricError; const useRegions = this._useEllipsoid(); if ( useRegions ) { const [ minU, minV, maxU, maxV ] = tiling.getTileBounds( x, y, level, true ); const { tilePixelWidth, tilePixelHeight } = tiling.getLevel( level ); // one pixel width in uv space const tileUWidth = ( maxU - minU ) / tilePixelWidth; const tileVWidth = ( maxV - minV ) / tilePixelHeight; // calculate the region ranges const [ /* west */, south, east, north ] = tiling.getTileBounds( x, y, level ); // calculate the changes in lat / lon at the given point // find the most bowed point of the latitude range since the amount that latitude changes is // dependent on the Y value of the image const midLat = ( south > 0 ) !== ( north > 0 ) ? 0 : Math.min( Math.abs( south ), Math.abs( north ) ); const midV = projection.convertLatitudeToNormalized( midLat ); const lonFactor = projection.getLongitudeDerivativeAtNormalized( minU ); const latFactor = projection.getLatitudeDerivativeAtNormalized( midV ); // calculate the size of a pixel on the surface const [ xDeriv, yDeriv ] = getCartographicToMeterDerivative( this.tiles.ellipsoid, midLat, east ); geometricError = Math.max( tileUWidth * lonFactor * xDeriv, tileVWidth * latFactor * yDeriv ); } else { // Calculate geometric error: size of one pixel in world space. // The tile contents span [0, 1] along Y and [0, aspectRatio] along X. const { pixelWidth, pixelHeight } = tiling.getLevel( level ); geometricError = Math.max( tiling.aspectRatio / pixelWidth, 1 / pixelHeight ); } // Generate the node return { refine: 'REPLACE', geometricError, boundingVolume: this.createBoundingVolume( x, y, level, useRegions ? geometricError : 0 ), content: { uri: this.getUrl( x, y, level ), }, children: [], // save the tile params so we can expand later [ TILE_X ]: x, [ TILE_Y ]: y, [ TILE_LEVEL ]: level, }; } expandChildren( tile ) { const level = tile[ TILE_LEVEL ]; const x = tile[ TILE_X ]; const y = tile[ TILE_Y ]; const { tileSplitX, tileSplitY } = this._tiling.getLevel( level ); for ( let cx = 0; cx < tileSplitX; cx ++ ) { for ( let cy = 0; cy < tileSplitY; cy ++ ) { const child = this.createChild( tileSplitX * x + cx, tileSplitY * y + cy, level + 1 ); if ( child ) { tile.children.push( child ); } } } } _createDefaultTiling() { const tiling = new TilingScheme(); if ( this.shape === 'ellipsoid' ) { const projection = new ProjectionScheme( 'EPSG:3857' ); tiling.setProjection( projection ); tiling.generateLevels( DEFAULT_LEVELS, projection.tileCountX, projection.tileCountY ); } else { const projection = new ProjectionScheme( 'none' ); tiling.setProjection( projection ); tiling.generateLevels( DEFAULT_LEVELS, 1, 1 ); } return tiling; } }