UNPKG

3d-tiles-renderer

Version:

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

744 lines (513 loc) 19.5 kB
/** @import { Camera, Scene } from 'three' */ import { Group, Matrix4 } from 'three'; import { MVTHierarchy } from './MVTHierarchy.js'; import { DelayedScreenOccupationManager } from './DelayedScreenOccupationManager.js'; import { SettlingManager } from './SettlingManager.js'; import { TextAnchorManager } from './TextAnchorManager.js'; import { OccupancyGridOverlay } from './debug/OccupancyGridOverlay.js'; import { LineAnnotationOverlay } from './debug/LineAnnotationOverlay.js'; import { LineAnnotation, parseLineAnnotations } from './annotations/LineAnnotation.js'; import { forEachTileInBounds, getMeshesCartographicRange } from '../images/overlays/utils.js'; import { parsePointAnnotations } from './annotations/PointAnnotation.js'; import { HierarchyOverlay } from './debug/HierarchyOverlay.js'; import { PointAnnotationManager } from './annotations/PointAnnotationManager.js'; import { TextAnchorAnnotation } from './annotations/TextAnchorAnnotation.js'; import { MVTIconGlyphs } from './MVTIconGlyphs.js'; import { MVTLabelGlyphs } from './MVTLabelGlyphs.js'; const _matrix = /* @__PURE__ */ new Matrix4(); // provide all meshes in the scene function collectMeshes( object ) { const meshes = []; object.traverse( c => { if ( c.isMesh ) { meshes.push( c ); } } ); return meshes; } /** * @callback GetAnnotationCallback * @param {string} layerName - The MVT layer name the feature belongs to. * @param {Object} properties - The feature's property map. * @returns {boolean} Return true to include this feature as an annotation. */ /** * Bundles the callbacks the "MVTAnnotationsPlugin" needs into a single object. Subclass and override * the methods to customize which features become annotations, their placement priority, per-character * sizing, the displayed text, and how visibility changes are rendered. By default all points of interest * are rendered as circles and labels are rendered as white text with a black outline. Custom implementations * can be used for more sophisticated text rendering, variable font weights based on properties, and custom * icons. */ export class MVTAnnotationsDriver { /** * Set to "true" when the filters or settings have changed to trigger an * update to the annotations in the plugin. * @type {boolean} */ set needsUpdate( v ) { if ( v ) { this.version ++; } } constructor() { /** * Render group for the driver's own three.js objects. The plugin mounts it under * `tiles.group` on `init` and removes it on `dispose`; add any objects the driver draws to it. * @type {Group} */ this.group = new Group(); this.version = 0; } /** * Whether an MVT feature should be included as an annotation. * @param {string} layer - The MVT layer name the feature belongs to. * @param {Object} properties - The feature's property map. * @param {number} type - The MVT geometry type: `1` = point, `2` = line. * @returns {boolean} True to include the feature as an annotation. */ filterAnnotation( layer, properties, type ) { return false; } /** * Relative placement priority between two annotations, following the `Array.prototype.sort` * contract. Lower values sort first, are placed first, and win collisions. * @param {Object} a - The first annotation. * @param {Object} b - The second annotation. * @returns {number} Negative if `a` precedes `b`, positive if it follows, `0` if equal. */ sortAnnotations( a, b ) { const rankA = a.properties[ 'rank' ] ?? 1e10; const rankB = b.properties[ 'rank' ] ?? 1e10; return rankA - rankB; } /** * Advance width of a single character, in pixels, used to space glyphs along text labels. * @param {string} char - The character to measure. * @param {layer} layer - The layer associated with the text. * @param {Object} properties - The properties associated with the text. * @returns {number} The advance width in pixels. */ measureChar( char, layer, properties ) { return 1; } /** * The string a line / road annotation should display for the given feature. * @param {Object} properties - The feature's property map. * @returns {string} The label text, or an empty string to render nothing. */ getText( properties ) { return properties.name ?? ''; } /** * Whether a parsed annotation should currently be displayed. Unlike `filterAnnotation` which * decides what is parsed once. * @param {Object} properties - The feature's property map. * @param {number} type - The MVT geometry type: `1` = point, `2` = line. * @returns {boolean} True to display the annotation. */ isAnnotationEnabled( properties, type ) { return true; } /** * Called each frame with the point ( PoI ) annotations whose visibility changed, for the caller * to render. * @param {Object[]} added - Point annotations that became visible this frame. * @param {Object[]} removed - Point annotations that became hidden this frame. * @returns {void} */ onPointsUpdate( added, removed ) {} /** * Called each frame with the line / label annotations whose visibility changed, for the caller * to render. * @param {Object[]} added - Label annotations that became visible this frame. * @param {Object[]} removed - Label annotations that became hidden this frame. * @returns {void} */ onLabelsUpdate( added, removed ) {} /** * Releases any resources the driver created (geometries, materials, textures, etc.). Called by * the plugin from its own `dispose`. * @returns {void} */ dispose() {} } // split a mixed set of occupancy annotations into point ( PoI ) and label ( text anchor ) lists function splitAnnotations( set ) { const points = []; const labels = []; for ( const item of set ) { if ( item instanceof TextAnchorAnnotation ) { labels.push( item ); } else { points.push( item ); } } return { points, labels }; } /** * Ready-to-use driver so `new MVTAnnotationsPlugin( { overlay } )` displays something without any * setup. Every point feature is drawn as a filled white circle and every named line as white, * black-outlined Arial text. No feature filtering is applied. Supply a custom `MVTAnnotationsDriver` * to the plugin to override this behavior. * @private * @extends MVTAnnotationsDriver */ export class DefaultMVTAnnotationsDriver extends MVTAnnotationsDriver { constructor() { super(); const dpr = window.devicePixelRatio; // a single filled circle glyph, used for every point annotation const icons = new MVTIconGlyphs( { fallback: 'default' } ); icons.glyphAtlas.drawChar( 'default', '●', { fillStyle: 'white', strokeStyle: 'black', strokeWidth: 3 * dpr, font: '30px sans-serif', } ); // white Arial road labels with a black outline const labels = new MVTLabelGlyphs( { fontFamily: 'Arial', strokeStyle: 'black', strokeWidth: 3 * dpr, } ); this.group.add( icons, labels ); this.icons = icons; this.labels = labels; } // include every feature filterAnnotation( layer, properties, type ) { return true; } measureChar( char, layer, properties ) { return this.labels.measureChar( char ); } onPointsUpdate( added, removed ) { this.icons.update( added, removed ); } onLabelsUpdate( added, removed ) { this.labels.update( added, removed ); } dispose() { this.icons.dispose(); this.labels.dispose(); } } /** * Plugin that extracts point features from an MVT overlay and manages their screen-space * occupation, preventing label crowding via a hierarchical lock system and raycasted depth * placement. Rendering is left entirely to the caller via the driver's `onPointsUpdate` / * `onLabelsUpdate`. * @param {Object} options * @param {Object} options.overlay - The `PMTilesOverlay` (or compatible overlay) whose tile * content is parsed for point features. * @param {Camera} [options.camera=null] - Initial camera. Can be updated with `setCamera()`. * @param {MVTAnnotationsDriver} [options.driver] - Supplies the annotation callbacks: feature * filtering, placement priority, per-character sizing, and render updates. Cannot be changed * once initialized. * @param {number|null} [options.resolution=50] - Target resolution used when selecting the * vector tile level to load. This is equivalent to "resolution" value in ImageOverlayPlugin * used to drive loaded levels of detail for the overlays. Lower values load coarser tiles with * fewer annotations, independently of the shared overlay's own resolution. Set to null to use * the overlay resolution. Cannot be changed once initialized. */ export class MVTAnnotationsPlugin { get contentCache() { return this.overlay.imageSource._contentCache; } constructor( options = {} ) { // plugin fields this.priority = Infinity; this.name = 'MVT_ANNOTATIONS_PLUGIN'; const { overlay, camera = null, driver = new DefaultMVTAnnotationsDriver(), resolution = 50, } = options; // user settings this.overlay = overlay; this.camera = camera; this.driver = driver; this.resolution = resolution; // annotations call these live each frame so driver changes take effect immediately this._measureChar = char => this.driver.measureChar( char ); this._filterAnnotation = ( layer, properties, type ) => this.driver.filterAnnotation( layer, properties, type ); this._driverVersion = - 1; // hierarchy for managing tile loading and visibility this.hierarchy = new MVTHierarchy(); this.occupancy = new DelayedScreenOccupationManager(); this.anchorManager = new TextAnchorManager(); this.pointManager = new PointAnnotationManager(); this.settlingManager = new SettlingManager(); this.tileLoadState = new Map(); this.vectorTileInfo = new Map(); // debug overlays this.debug = { occupancy: new OccupancyGridOverlay( this.occupancy ), paths: new LineAnnotationOverlay( this.anchorManager ), hierarchy: new HierarchyOverlay(), }; } async init( tiles ) { // init this.tiles = tiles; // mount the driver's render group under the tile group tiles.group.add( this.driver.group ); this.driver.group.updateMatrixWorld(); const { overlay, occupancy, debug, hierarchy, settlingManager, contentCache, pointManager, anchorManager, } = this; // init debug debug.paths.group = tiles.group; debug.hierarchy.hierarchy = hierarchy; debug.hierarchy.tiles = tiles; debug.hierarchy.tiling = overlay.tiling; settlingManager.occupancy = occupancy; settlingManager.tiles = tiles; hierarchy.contentCache = contentCache; // ensure the overlay is initialized overlay.init(); if ( ! overlay.isReady ) { await overlay.whenReady(); } // init occupancy occupancy.sortCallback = ( a, b ) => { // visibility is prioritized first const aVis = occupancy.visible.has( a ); const bVis = occupancy.visible.has( b ); if ( aVis !== bVis ) { return aVis ? - 1 : 1; } const sort = this.driver.sortAnnotations( a, b ); if ( sort !== 0 ) { // user sort return sort; } else if ( a.lodLevel !== b.lodLevel ) { // lod sort return b.lodLevel - a.lodLevel; } // if both items have been around for awhile (5 seconds) then just // just fall through to other sort mechanisms. const shortDuration = a.visibleDuration < 5000 || b.visibleDuration < 5000; if ( aVis && shortDuration && a.visibleTime !== b.visibleTime ) { // persistence sort for visual stability return a.visibleTime < b.visibleTime ? - 1 : 1; } else if ( b.screenPos.y !== a.screenPos.y ) { // distance up the screen return b.screenPos.y - a.screenPos.y; } else { return a.id > b.id ? 1 : - 1; } }; // event callbacks this._onVisibilityChange = ( { scene, tile, visible } ) => { // tile geometry changed — existing items may have been settled on this geometry // and need to be re-settled against the updated scene settlingManager.needsUpdate = true; // TODO: the ImageOverlay Tile Splits is causing an issue here since we can't // automatically load higher res data than what the tiles are allowing this._markVectorTile( tile, visible ); }; this._onUpdateAfter = () => { const { driver, camera, _measureChar } = this; const annotationsNeedUpdate = driver.version !== this._driverVersion; this._driverVersion = driver.version; if ( annotationsNeedUpdate ) { // Recalculate the field state per annotation for ( const annotation of pointManager.points ) { annotation.enabled = driver.isAnnotationEnabled( annotation.layer, annotation.properties, 1 ); } for ( const line of anchorManager.lines ) { line.enabled = driver.isAnnotationEnabled( line.layer, line.properties, 2 ); line.text = driver.getText( line.properties ); line.updateCharacterWidthCache( _measureChar ); } settlingManager.needsUpdate = true; occupancy.needsUpdate = true; } // sync camera and localToWorld matrix into occupancy grid if ( camera !== null ) { tiles.getResolution( camera, occupancy.resolution ); occupancy.matrix.copy( tiles.group.matrixWorld ); } // update all sub managers hierarchy.update(); // point annotations pointManager.update(); pointManager.added.forEach( item => { occupancy.register( item ); settlingManager.register( item ); } ); pointManager.removed.forEach( item => { occupancy.unregister( item ); settlingManager.unregister( item ); } ); pointManager.reset(); // text anchors anchorManager.update(); anchorManager.added.forEach( item => { occupancy.register( item ); } ); anchorManager.removed.forEach( item => { occupancy.unregister( item ); } ); anchorManager.reset(); // mark the occupancy manager as needing an update if there is settling work to // be done. occupancy.needsUpdate = occupancy.needsUpdate || settlingManager.hasPendingWork; // raycasters settlingManager.camera = camera; settlingManager.update(); // occupancy occupancy.camera = camera; occupancy.update(); // when the driver's filters changed, complete the sliced occupancy pass and force the // animations to completion so the change is applied at once rather than delayed if ( annotationsNeedUpdate ) { occupancy.flush(); occupancy.finishAnimations(); } // split the visibility changes by kind and notify the driver's renderers const added = splitAnnotations( occupancy.added ); const removed = splitAnnotations( occupancy.removed ); this.driver.onPointsUpdate( added.points, removed.points ); this.driver.onLabelsUpdate( added.labels, removed.labels ); if ( occupancy.added.size > 0 || occupancy.removed.size > 0 ) { tiles.dispatchEvent( { type: 'needs-render' } ); } if ( occupancy.hasPendingWork || settlingManager.hasPendingWork ) { tiles.dispatchEvent( { type: 'needs-update' } ); } // debug debug.paths.camera = this.camera; debug.occupancy.update(); debug.paths.update(); debug.hierarchy.update(); }; this._onVectorTileToggle = ( { x, y, level, visible } ) => { tiles.dispatchEvent( { type: 'needs-update' } ); const { contentCache, driver, vectorTileInfo, settlingManager, anchorManager, pointManager, _filterAnnotation, _measureChar, } = this; const key = `${ x }_${ y }_${ level }`; if ( visible ) { const { tiling } = overlay; const vectorTile = contentCache.get( x, y, level ); if ( ! vectorTile ) { vectorTileInfo.set( key, { annotations: [] } ); return; } // parse the icon annotations const annotations = []; parsePointAnnotations( vectorTile, x, y, level, tiling, _filterAnnotation, annotations ); parseLineAnnotations( vectorTile, x, y, level, tiling, _filterAnnotation, annotations ); vectorTileInfo.set( key, { annotations } ); for ( const ann of annotations ) { if ( ann instanceof LineAnnotation ) { settlingManager.register( ann ); ann.enabled = driver.isAnnotationEnabled( ann.layer, ann.properties, 2 ); ann.text = driver.getText( ann.properties ); ann.updateCharacterWidthCache( _measureChar ); } else { pointManager.add( ann ); ann.enabled = driver.isAnnotationEnabled( ann.layer, ann.properties, 1 ); } } // add the anchors anchorManager.addLines( annotations.filter( ann => ann instanceof LineAnnotation ) ); } else { const { annotations } = vectorTileInfo.get( key ); vectorTileInfo.delete( key ); for ( const item of annotations ) { if ( item instanceof LineAnnotation ) { settlingManager.unregister( item ); } else { pointManager.delete( item ); } } // remove the anchors anchorManager.deleteLines( annotations.filter( ann => ann instanceof LineAnnotation ) ); } }; this._onDisposeModel = ( { tile } ) => { this.tileLoadState.delete( tile ); }; // register events hierarchy.addEventListener( 'toggle', this._onVectorTileToggle ); tiles.addEventListener( 'update-after', this._onUpdateAfter ); tiles.addEventListener( 'tile-visibility-change', this._onVisibilityChange ); tiles.addEventListener( 'dispose-model', this._onDisposeModel ); // // late initialization tiles.forEachLoadedModel( ( scene, tile ) => { this.processTileModel( scene, tile ); if ( tiles.visibleTiles.has( tile ) ) { this._markVectorTile( tile, true ); } } ); } dispose() { const { debug, tiles, hierarchy, tileLoadState } = this; debug.occupancy.dispose(); debug.paths.dispose(); // unmount and dispose the driver's render group tiles.group.remove( this.driver.group ); this.driver.dispose(); hierarchy.removeEventListener( 'toggle', this._onVectorTileToggle ); tiles.removeEventListener( 'update-after', this._onUpdateAfter ); tiles.removeEventListener( 'tile-visibility-change', this._onVisibilityChange ); tiles.removeEventListener( 'dispose-model', this._onDisposeModel ); // visible tiles are the ones currently marked in the hierarchy tileLoadState.forEach( ( range, tile ) => { if ( tiles.visibleTiles.has( tile ) ) { this._markVectorTile( tile, false ); } } ); } processTileModel( scene, tile ) { const { tiles, overlay } = this; // TODO: this currently only work with ellipsoidal projection _matrix.identity(); if ( scene.parent !== null ) { _matrix.copy( tiles.group.matrixWorldInverse ); } // TODO: why are we passing range vs region here? scene.updateMatrixWorld(); const meshes = collectMeshes( scene ); const { range } = getMeshesCartographicRange( meshes, tiles.ellipsoid, _matrix, overlay.projection ); // TODO: why not process here? this.tileLoadState.set( tile, range ); } // _markVectorTile( tile, state ) { const range = this.tileLoadState.get( tile ); this._forEachTileInBounds( range, ( x, y, l ) => { this.hierarchy.setTargetState( x, y, l, state ); } ); } _forEachTileInBounds( range, callback ) { // iterate over every mvt tile in the overlay const { overlay, resolution } = this; const { tiling } = overlay; const level = overlay.calculateLevel( range, resolution ); if ( ! overlay.isReady ) { throw new Error( 'MVTAnnotationsPlugin: overlay is not ready.' ); } forEachTileInBounds( range, level, tiling, callback ); } }