3d-tiles-renderer
Version:
https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification
744 lines (513 loc) • 19.5 kB
JavaScript
/** @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 );
}
}