UNPKG

3d-tiles-renderer

Version:

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

1,183 lines (736 loc) 24.6 kB
/** @import { Object3D } from 'three' */ import { Box3Helper, Group, MeshStandardMaterial, PointsMaterial, Sphere, Color, MeshBasicMaterial, Mesh, BoxGeometry, SphereGeometry, DoubleSide } from 'three'; import { SphereHelper } from './objects/SphereHelper.js'; import { EllipsoidRegionLineHelper, EllipsoidRegionHelper } from './objects/EllipsoidRegionHelper.js'; import { TraversalUtils } from '3d-tiles-renderer/core'; const ORIGINAL_MATERIAL = Symbol( 'ORIGINAL_MATERIAL' ); const HAS_RANDOM_COLOR = Symbol( 'HAS_RANDOM_COLOR' ); const HAS_RANDOM_NODE_COLOR = Symbol( 'HAS_RANDOM_NODE_COLOR' ); const LOAD_TIME = Symbol( 'LOAD_TIME' ); const PARENT_BOUND_REF_COUNT = Symbol( 'PARENT_BOUND_REF_COUNT' ); const _sphere = /* @__PURE__ */ new Sphere(); const emptyRaycast = () => {}; const colors = {}; // Return a consistent random color for an index function getIndexedRandomColor( index ) { if ( ! colors[ index ] ) { const h = Math.random(); const s = 0.5 + Math.random() * 0.5; const l = 0.375 + Math.random() * 0.25; colors[ index ] = new Color().setHSL( h, s, l ); } return colors[ index ]; } // color modes const NONE = 0; const SCREEN_ERROR = 1; const GEOMETRIC_ERROR = 2; const DISTANCE = 3; const DEPTH = 4; const RELATIVE_DEPTH = 5; const IS_LEAF = 6; const RANDOM_COLOR = 7; const RANDOM_NODE_COLOR = 8; const CUSTOM_COLOR = 9; const LOAD_ORDER = 10; const INDEXED_COLOR = 11; const ColorModes = Object.freeze( { NONE, SCREEN_ERROR, GEOMETRIC_ERROR, DISTANCE, DEPTH, RELATIVE_DEPTH, IS_LEAF, RANDOM_COLOR, RANDOM_NODE_COLOR, CUSTOM_COLOR, LOAD_ORDER, INDEXED_COLOR, } ); /** * @callback GetDebugColorCallback * @param {number} val Normalized [0, 1] value. * @param {Color} target Color to write the result into. */ /** * @callback CustomColorCallback * @param {Object} tile The tile whose scene is being colored. * @param {Object3D} child The object within the tile scene being colored. Has a `material` property. */ /** * Plugin that adds visual debugging aids to a `TilesRenderer`: bounding-volume * helpers (box, sphere, region), tile color modes based on depth/error/distance/load * order, and an unlit rendering mode. Color modes are available via the static * `ColorModes` property. * @param {Object} [options] * @param {boolean} [options.displayBoxBounds=false] Show OBB bounding-box helpers. * @param {boolean} [options.displaySphereBounds=false] Show bounding-sphere helpers. * @param {boolean} [options.displayRegionBounds=false] Show bounding-region helpers. * @param {boolean} [options.displayParentBounds=false] Also show ancestor bounding volumes for visible tiles. * @param {number} [options.colorMode=ColorModes.NONE] Initial tile color mode. * @param {number} [options.boundsColorMode=ColorModes.NONE] Color mode applied to bounding-volume helpers. * @param {number} [options.maxDebugDepth=-1] Maximum tree depth for depth-based coloring (`-1` = auto). * @param {number} [options.maxDebugDistance=-1] Maximum distance for distance-based coloring (`-1` = auto). * @param {number} [options.maxDebugError=-1] Maximum error for error-based coloring (`-1` = auto). * @param {CustomColorCallback|null} [options.customColorCallback=null] Callback invoked per-object when `colorMode` is `CUSTOM_COLOR`. * @param {boolean} [options.unlit=false] Replace tile materials with unlit `MeshBasicMaterial`. * @param {boolean} [options.enabled=true] Whether the plugin is active on init. */ export class DebugTilesPlugin { static get ColorModes() { return ColorModes; } get wireframe() { return this._wireframe; } set wireframe( v ) { if ( v !== this._wireframe ) { this._wireframe = v; this.materialsNeedUpdate = true; } } get unlit() { return this._unlit; } set unlit( v ) { if ( v !== this._unlit ) { this._unlit = v; this.materialsNeedUpdate = true; } } get colorMode() { return this._colorMode; } set colorMode( v ) { if ( v !== this._colorMode ) { this._colorMode = v; this.materialsNeedUpdate = true; } } get boundsColorMode() { return this._boundsColorMode; } set boundsColorMode( v ) { if ( v !== this._boundsColorMode ) { this._boundsColorMode = v; this.materialsNeedUpdate = true; } } get enabled() { return this._enabled; } set enabled( v ) { if ( v !== this._enabled && this.tiles !== null ) { this._enabled = v; if ( v ) { this.init( this.tiles ); } else { this.dispose(); } } } get displayParentBounds() { return this._displayParentBounds; } set displayParentBounds( v ) { if ( this._displayParentBounds !== v ) { this._displayParentBounds = v; if ( ! v ) { // Reset all ref counts this.tiles.traverse( tile => { tile[ PARENT_BOUND_REF_COUNT ] = null; this._onTileVisibilityChange( tile, tile.traversal.visible ); } ); } else { // Initialize ref count for existing tiles this.tiles.traverse( tile => { if ( tile.traversal.visible ) { this._onTileVisibilityChange( tile, true ); } } ); } } } constructor( options ) { options = { displayParentBounds: false, displayBoxBounds: false, displaySphereBounds: false, displayRegionBounds: false, colorMode: NONE, boundsColorMode: NONE, maxDebugDepth: - 1, maxDebugDistance: - 1, maxDebugError: - 1, customColorCallback: null, unlit: false, enabled: true, ...options, }; this.name = 'DEBUG_TILES_PLUGIN'; this.tiles = null; this._colorMode = null; this._boundsColorMode = null; this._unlit = null; this._wireframe = null; this.materialsNeedUpdate = false; this.extremeDebugDepth = - 1; this.extremeDebugError = - 1; this.boxGroup = null; this.sphereGroup = null; this.regionGroup = null; // options this._enabled = options.enabled; this._displayParentBounds = options.displayParentBounds; this.displayBoxBounds = options.displayBoxBounds; this.displaySphereBounds = options.displaySphereBounds; this.displayRegionBounds = options.displayRegionBounds; this.colorMode = options.colorMode; this.boundsColorMode = options.boundsColorMode; this.maxDebugDepth = options.maxDebugDepth; this.maxDebugDistance = options.maxDebugDistance; this.maxDebugError = options.maxDebugError; this.customColorCallback = options.customColorCallback; this.unlit = options.unlit; this.wireframe = options.wireframe; /** * Maps a normalized [0, 1] value to a `Color` for debug visualizations. Defaults to * a black-to-white gradient. Replace with a custom function to use a different color * ramp. * @type {GetDebugColorCallback} * @default ( value, target ) => target.setRGB( value, value, value ) */ this.getDebugColor = ( value, target ) => { target.setRGB( value, value, value ); }; } // initialize the groups for displaying helpers, register events, and initialize existing tiles init( tiles ) { this.tiles = tiles; if ( ! this.enabled ) { return; } // initialize groups const tilesGroup = tiles.group; this.boxGroup = new Group(); this.boxGroup.name = 'DebugTilesRenderer.boxGroup'; tilesGroup.add( this.boxGroup ); this.boxGroup.updateMatrixWorld(); this.sphereGroup = new Group(); this.sphereGroup.name = 'DebugTilesRenderer.sphereGroup'; tilesGroup.add( this.sphereGroup ); this.sphereGroup.updateMatrixWorld(); this.regionGroup = new Group(); this.regionGroup.name = 'DebugTilesRenderer.regionGroup'; tilesGroup.add( this.regionGroup ); this.regionGroup.updateMatrixWorld(); // register events this._onLoadTilesetCB = () => { this._initExtremes(); }; this._onLoadModelCB = ( { scene, tile } ) => { this._onLoadModel( scene, tile ); }; this._onDisposeModelCB = ( { tile } ) => { this._onDisposeModel( tile ); }; this._onUpdateAfterCB = () => { this.update(); }; this._onTileVisibilityChangeCB = ( { scene, tile, visible } ) => { this._onTileVisibilityChange( tile, visible ); }; tiles.addEventListener( 'load-tileset', this._onLoadTilesetCB ); tiles.addEventListener( 'load-model', this._onLoadModelCB ); tiles.addEventListener( 'dispose-model', this._onDisposeModelCB ); tiles.addEventListener( 'update-after', this._onUpdateAfterCB ); tiles.addEventListener( 'tile-visibility-change', this._onTileVisibilityChangeCB ); this._initExtremes(); // initialize an already-loaded tiles tiles.traverse( tile => { if ( tile.engineData.scene ) { this._onLoadModel( tile.engineData.scene, tile ); } } ); tiles.visibleTiles.forEach( tile => { this._onTileVisibilityChange( tile, true ); } ); } getTileFromObject3D( object ) { // Find which tile this scene is associated with. This is slow and // intended for debug purposes only. let result = null; const activeTiles = this.tiles.activeTiles; activeTiles.forEach( tile => { if ( result ) { return; } const scene = tile.engineData.scene; if ( scene ) { scene.traverse( c => { if ( c === object ) { result = tile; } } ); } } ); return result; } setEmptyTileVisible( tile, visible ) { this._onTileVisibilityChange( tile, visible ); } _initExtremes() { if ( ! ( this.tiles && this.tiles.root ) ) { return; } // initialize the extreme values of the hierarchy let maxDepth = - 1; let maxError = - 1; // Note that we are not using this.tiles.traverse() // as we don't want to pay the cost of preprocessing tiles. this.tiles.traverse( null, ( tile, _, depth ) => { maxDepth = Math.max( maxDepth, depth ); maxError = Math.max( maxError, tile.geometricError ); }, false ); this.extremeDebugDepth = maxDepth; this.extremeDebugError = maxError; } /** * Applies the current plugin field values to all visible tile geometry. Call this * after modifying properties such as `colorMode`, `displayBoxBounds`, or * `displayParentBounds` when `TilesRenderer.update` is not being called every frame * so changes can be reflected. */ update() { const { tiles, colorMode, boundsColorMode } = this; if ( ! tiles.root ) { return; } if ( this.materialsNeedUpdate ) { tiles.forEachLoadedModel( scene => { this._updateMaterial( scene ); } ); this.materialsNeedUpdate = false; } // set box or sphere visibility this.boxGroup.visible = this.displayBoxBounds; this.sphereGroup.visible = this.displaySphereBounds; this.regionGroup.visible = this.displayRegionBounds; // get max values to use for materials let maxDepth = - 1; if ( this.maxDebugDepth === - 1 ) { maxDepth = this.extremeDebugDepth; } else { maxDepth = this.maxDebugDepth; } let maxError = - 1; if ( this.maxDebugError === - 1 ) { maxError = this.extremeDebugError; } else { maxError = this.maxDebugError; } let maxDistance = - 1; if ( this.maxDebugDistance === - 1 ) { tiles.getBoundingSphere( _sphere ); maxDistance = _sphere.radius; } else { maxDistance = this.maxDebugDistance; } const { errorTarget, visibleTiles } = tiles; let sortedTiles; if ( colorMode === LOAD_ORDER || boundsColorMode === LOAD_ORDER ) { sortedTiles = Array.from( visibleTiles ).sort( ( a, b ) => { return a[ LOAD_TIME ] - b[ LOAD_TIME ]; } ); } // Set the color on the material based on the given color mode const applyColor = ( mode, tile, child, h, s, l ) => { if ( mode !== RANDOM_COLOR ) { delete child.material[ HAS_RANDOM_COLOR ]; } if ( mode !== RANDOM_NODE_COLOR ) { delete child.material[ HAS_RANDOM_NODE_COLOR ]; } switch ( mode ) { case DEPTH: { const val = tile.internal.depth / maxDepth; this.getDebugColor( val, child.material.color ); break; } case RELATIVE_DEPTH: { const val = tile.internal.depthFromRenderedParent / maxDepth; this.getDebugColor( val, child.material.color ); break; } case SCREEN_ERROR: { const val = tile.traversal.error / errorTarget; if ( val > 1.0 ) { child.material.color.setRGB( 1.0, 0.0, 0.0 ); } else { this.getDebugColor( val, child.material.color ); } break; } case GEOMETRIC_ERROR: { const val = Math.min( tile.geometricError / maxError, 1 ); this.getDebugColor( val, child.material.color ); break; } case DISTANCE: { // We don't update the distance if the geometric error is 0.0 so // it will always be black. const val = Math.min( tile.traversal.distanceFromCamera / maxDistance, 1 ); this.getDebugColor( val, child.material.color ); break; } case IS_LEAF: { if ( ! tile.children || tile.children.length === 0 ) { this.getDebugColor( 1.0, child.material.color ); } else { this.getDebugColor( 0.0, child.material.color ); } break; } case RANDOM_NODE_COLOR: { if ( ! child.material[ HAS_RANDOM_NODE_COLOR ] ) { child.material.color.setHSL( h, s, l ); child.material[ HAS_RANDOM_NODE_COLOR ] = true; } break; } case RANDOM_COLOR: { if ( ! child.material[ HAS_RANDOM_COLOR ] ) { child.material.color.setHSL( h, s, l ); child.material[ HAS_RANDOM_COLOR ] = true; } break; } case CUSTOM_COLOR: { if ( this.customColorCallback ) { this.customColorCallback( tile, child ); } else { console.warn( 'DebugTilesRenderer: customColorCallback not defined' ); } break; } case LOAD_ORDER: { const value = sortedTiles.indexOf( tile ); this.getDebugColor( value / ( sortedTiles.length - 1 ), child.material.color ); break; } case INDEXED_COLOR: { child.material.color.copy( getIndexedRandomColor( tile.internal.depth ) ); delete child.material[ HAS_RANDOM_COLOR ]; delete child.material[ HAS_RANDOM_NODE_COLOR ]; break; } } }; // update tile materials visibleTiles.forEach( tile => { const scene = tile.engineData.scene; // create a random color per-tile let h, s, l; if ( colorMode === RANDOM_COLOR ) { h = Math.random(); s = 0.5 + Math.random() * 0.5; l = 0.375 + Math.random() * 0.25; } scene.traverse( c => { if ( colorMode === RANDOM_NODE_COLOR ) { h = Math.random(); s = 0.5 + Math.random() * 0.5; l = 0.375 + Math.random() * 0.25; } if ( c.material ) { applyColor( colorMode, tile, c, h, s, l ); } } ); } ); // update bounds helper colors const effectiveBoundsColorMode = boundsColorMode === NONE ? INDEXED_COLOR : boundsColorMode; const groups = [ this.boxGroup, this.sphereGroup, this.regionGroup ]; for ( const group of groups ) { for ( const helper of group.children ) { const tile = helper.userData.tile; let h, s, l; if ( effectiveBoundsColorMode === RANDOM_COLOR ) { h = Math.random(); s = 0.5 + Math.random() * 0.5; l = 0.375 + Math.random() * 0.25; } helper.traverse( c => { if ( effectiveBoundsColorMode === RANDOM_NODE_COLOR ) { h = Math.random(); s = 0.5 + Math.random() * 0.5; l = 0.375 + Math.random() * 0.25; } if ( c.material ) { applyColor( effectiveBoundsColorMode, tile, c, h, s, l ); } } ); } } } _onTileVisibilityChange( tile, visible ) { if ( this.displayParentBounds ) { TraversalUtils.traverseAncestors( tile, current => { if ( current[ PARENT_BOUND_REF_COUNT ] == null ) { current[ PARENT_BOUND_REF_COUNT ] = 0; } if ( visible ) { current[ PARENT_BOUND_REF_COUNT ] ++; } else if ( current[ PARENT_BOUND_REF_COUNT ] > 0 ) { current[ PARENT_BOUND_REF_COUNT ] --; } const tileVisible = ( current === tile && visible ) || ( this.displayParentBounds && current[ PARENT_BOUND_REF_COUNT ] > 0 ); this._updateBoundHelper( current, tileVisible ); } ); } else { this._updateBoundHelper( tile, visible ); } } _createBoundHelper( tile ) { const tiles = this.tiles; const engineData = tile.engineData; const { sphere, obb, region } = engineData.boundingVolume; if ( obb ) { // Create debug bounding box // In some cases the bounding box may have a scale of 0 in one dimension resulting // in the NaNs in an extracted rotation so we disable matrix updates instead. const boxHelperGroup = new Group(); boxHelperGroup.name = 'DebugTilesRenderer.boxHelperGroup'; boxHelperGroup.matrix.copy( obb.transform ); boxHelperGroup.matrixAutoUpdate = false; boxHelperGroup.userData.tile = tile; engineData.boxHelperGroup = boxHelperGroup; const boxHelper = new Box3Helper( obb.box, getIndexedRandomColor( tile.internal.depth ) ); boxHelper.raycast = emptyRaycast; boxHelperGroup.add( boxHelper ); // Create partially transparent mesh const fillMesh = new Mesh( new BoxGeometry(), new MeshBasicMaterial( { color: getIndexedRandomColor( tile.internal.depth ), transparent: true, depthWrite: false, opacity: 0.05, side: DoubleSide, } ) ); obb.box.getSize( fillMesh.scale ); fillMesh.raycast = emptyRaycast; boxHelperGroup.add( fillMesh ); if ( tiles.visibleTiles.has( tile ) && this.displayBoxBounds ) { this.boxGroup.add( boxHelperGroup ); boxHelperGroup.updateMatrixWorld( true ); } } if ( sphere ) { // Create debug bounding sphere const sphereHelper = new SphereHelper( sphere, getIndexedRandomColor( tile.internal.depth ) ); sphereHelper.raycast = emptyRaycast; sphereHelper.userData.tile = tile; // Create partially transparent mesh const sphereFillMesh = new Mesh( new SphereGeometry( 1 ), new MeshBasicMaterial( { color: getIndexedRandomColor( tile.internal.depth ), transparent: true, depthWrite: false, opacity: 0.05, side: DoubleSide, } ) ); sphereFillMesh.raycast = emptyRaycast; sphereHelper.add( sphereFillMesh ); engineData.sphereHelper = sphereHelper; if ( tiles.visibleTiles.has( tile ) && this.displaySphereBounds ) { this.sphereGroup.add( sphereHelper ); sphereHelper.updateMatrixWorld( true ); } } if ( region ) { // Create debug bounding region const regionHelper = new EllipsoidRegionLineHelper( region, getIndexedRandomColor( tile.internal.depth ) ); regionHelper.raycast = emptyRaycast; regionHelper.userData.tile = tile; // create partially transparent mesh const regionFillMesh = new EllipsoidRegionHelper( region, getIndexedRandomColor( tile.internal.depth ) ); regionFillMesh.material.transparent = true; regionFillMesh.material.depthWrite = false; regionFillMesh.material.opacity = 0.05; regionFillMesh.material.side = DoubleSide; regionFillMesh.raycast = emptyRaycast; regionHelper.add( regionFillMesh ); // recenter the geometry to avoid rendering artifacts const sphere = new Sphere(); region.getBoundingSphere( sphere ); regionHelper.position.copy( sphere.center ); sphere.center.multiplyScalar( - 1 ); regionHelper.geometry.translate( ...sphere.center ); regionFillMesh.geometry.translate( ...sphere.center ); engineData.regionHelper = regionHelper; if ( tiles.visibleTiles.has( tile ) && this.displayRegionBounds ) { this.regionGroup.add( regionHelper ); regionHelper.updateMatrixWorld( true ); } } } _updateHelperMaterials( tile, group ) { group.traverse( c => { const { material } = c; if ( ! material ) { return; } if ( tile.traversal.visible || ! this.displayParentBounds ) { material.opacity = c.isMesh ? 0.05 : 1; } else { material.opacity = c.isMesh ? 0.01 : 0.2; } const transparent = material.transparent; material.transparent = material.opacity < 1; if ( material.transparent !== transparent ) { material.needsUpdate = true; } } ); } _updateBoundHelper( tile, visible ) { const engineData = tile.engineData; if ( ! engineData ) { return; } const sphereGroup = this.sphereGroup; const boxGroup = this.boxGroup; const regionGroup = this.regionGroup; if ( visible && ( engineData.boxHelperGroup == null && engineData.sphereHelper == null && engineData.regionHelper == null ) ) { this._createBoundHelper( tile ); } const boxHelperGroup = engineData.boxHelperGroup; const sphereHelper = engineData.sphereHelper; const regionHelper = engineData.regionHelper; if ( ! visible ) { if ( boxHelperGroup ) { boxGroup.remove( boxHelperGroup ); } if ( sphereHelper ) { sphereGroup.remove( sphereHelper ); } if ( regionHelper ) { regionGroup.remove( regionHelper ); } } else { // TODO: consider updating the volumes based on the bounding regions here in case they've been changed if ( boxHelperGroup ) { boxGroup.add( boxHelperGroup ); boxHelperGroup.updateMatrixWorld( true ); this._updateHelperMaterials( tile, boxHelperGroup ); } if ( sphereHelper ) { sphereGroup.add( sphereHelper ); sphereHelper.updateMatrixWorld( true ); this._updateHelperMaterials( tile, sphereHelper ); } if ( regionHelper ) { regionGroup.add( regionHelper ); regionHelper.updateMatrixWorld( true ); this._updateHelperMaterials( tile, regionHelper ); } } } _updateMaterial( scene ) { // update the materials for debug rendering const { colorMode, unlit, wireframe } = this; scene.traverse( c => { if ( ! c.material ) { return; } const currMaterial = c.material; const originalMaterial = c[ ORIGINAL_MATERIAL ]; // dispose the previous material if ( currMaterial !== originalMaterial ) { currMaterial.dispose(); } // assign the new material if ( colorMode !== NONE || unlit ) { if ( c.isPoints ) { const pointsMaterial = new PointsMaterial(); pointsMaterial.size = originalMaterial.size; pointsMaterial.sizeAttenuation = originalMaterial.sizeAttenuation; c.material = pointsMaterial; } else if ( unlit ) { c.material = new MeshBasicMaterial( { wireframe: wireframe } ); } else { c.material = new MeshStandardMaterial( { wireframe: wireframe } ); c.material.flatShading = true; } // if no debug rendering is happening then assign the material properties if ( colorMode === NONE ) { c.material.map = originalMaterial.map; c.material.color.set( originalMaterial.color ); } } else { c.material = originalMaterial; } } ); } _onLoadModel( scene, tile ) { tile[ LOAD_TIME ] = performance.now(); // Cache the original materials scene.traverse( c => { const material = c.material; if ( material ) { c[ ORIGINAL_MATERIAL ] = material; } } ); // Update the materials to align with the settings this._updateMaterial( scene ); } _onDisposeModel( tile ) { const engineData = tile.engineData; if ( engineData?.boxHelperGroup ) { engineData.boxHelperGroup.traverse( c => { if ( c.geometry ) { c.geometry.dispose(); c.material.dispose(); } } ); delete engineData.boxHelperGroup; } if ( engineData?.sphereHelper ) { engineData.sphereHelper.traverse( c => { if ( c.geometry ) { c.geometry.dispose(); c.material.dispose(); } } ); delete engineData.sphereHelper; } if ( engineData?.regionHelper ) { engineData.regionHelper.traverse( c => { if ( c.geometry ) { c.geometry.dispose(); c.material.dispose(); } } ); delete engineData.regionHelper; } } dispose() { const tiles = this.tiles; tiles.removeEventListener( 'load-tileset', this._onLoadTilesetCB ); tiles.removeEventListener( 'load-model', this._onLoadModelCB ); tiles.removeEventListener( 'dispose-model', this._onDisposeModelCB ); tiles.removeEventListener( 'update-after', this._onUpdateAfterCB ); tiles.removeEventListener( 'tile-visibility-change', this._onTileVisibilityChangeCB ); // reset all materials this.colorMode = NONE; this.boundsColorMode = NONE; this.unlit = false; tiles.forEachLoadedModel( scene => { this._updateMaterial( scene ); } ); // dispose of all helper objects tiles.traverse( tile => { this._onDisposeModel( tile ); }, null, false ); this.boxGroup?.removeFromParent(); this.sphereGroup?.removeFromParent(); this.regionGroup?.removeFromParent(); } }