UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

591 lines (565 loc) 19.6 kB
import { TilesRenderer } from '3d-tiles-renderer'; import { DebugTilesPlugin, GLTFExtensionsPlugin, ImplicitTilingPlugin, UnloadTilesPlugin } from '3d-tiles-renderer/plugins'; import { Box3, Color, Group, REVISION, Vector3 } from 'three'; import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'; import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js'; import { defaultColorimetryOptions } from '../core/ColorimetryOptions'; import ColorMap from '../core/ColorMap'; import Extent from '../core/geographic/Extent'; import { getGeometryMemoryUsage } from '../core/MemoryUsage'; import PointCloudMaterial, { ASPRS_CLASSIFICATIONS, MODE } from '../renderer/PointCloudMaterial'; import { isBufferGeometry, isObject3D } from '../utils/predicates'; import { nonNull } from '../utils/tsutils'; import FetchPlugin from './3dtiles/FetchPlugin'; import PointCloudPlugin, { isPNTSScene } from './3dtiles/PointCloudPlugin'; import Entity3D from './Entity3D'; /** Options to create a Tiles3D object. */ const tmpBox3 = new Box3(); const tmpVector = new Vector3(); export function isLayerNode(obj) { if (obj == null) { return false; } if ('material' in obj && PointCloudMaterial.isPointCloudMaterial(obj.material)) { return true; } return false; } /** * Types of results for picking on {@link Tiles3D}. * * If Tiles3D uses {@link PointCloudMaterial}, then results will be of {@link PointsPickResult}. * Otherwise, they will be of {@link PickResult}. */ const perInstanceSharedResources = new Map(); function getSharedResources(instance) { return perInstanceSharedResources.get(instance) ?? null; } function setSharedResources(instance, resources) { if (perInstanceSharedResources.has(instance)) { return; } perInstanceSharedResources.set(instance, resources); instance.addEventListener('dispose', e => perInstanceSharedResources.delete(e.target)); } /** * Displays a [3D Tiles Tileset](https://www.ogc.org/publications/standard/3dtiles/). This entity * uses the [3d-tiles-renderer](https://github.com/NASA-AMMOS/3DTilesRendererJS) package. * * Note: shadow maps are supported, but require vertex normals on displayed objects, which * depends on the data. Many tilesets do not have vertex normals, as they increase the * size of the dataset. */ export default class Tiles3D extends Entity3D { isPickable = true; hasLayers = true; isTiles3D = true; type = 'Tiles3D'; _debugOptions = { displayBoxBounds: false, displaySphereBounds: false, displayRegionBounds: false }; // Settings that only applies to point cloud tiles _pointCloudParameters = { pointSize: 0, // Automatic size pointCloudMode: MODE.COLOR, colorimetry: defaultColorimetryOptions(), overlayColor: null, pointCloudColorMap: new ColorMap({ colors: [new Color('black'), new Color('white')], min: 0, max: 100 }), classifications: ASPRS_CLASSIFICATIONS.map(c => c.clone()) }; _objectOptions = { castShadow: false, receiveShadow: false }; _colorLayer = null; constructor(options) { super(new Group()); this._tiles = new TilesRenderer(options?.url); this._tiles.errorTarget = options?.errorTarget ?? 8; this.object3d.add(this._tiles.group); this._listeners = { onModelLoaded: this.onModelLoaded.bind(this), onTileVisibilityChanged: this.onTileVisibilityChanged.bind(this), onColorMapUpdated: this.onColorMapUpdated.bind(this), onTileDisposed: this.onTileDisposed.bind(this) }; this._tiles.addEventListener('load-model', this._listeners.onModelLoaded); this._tiles.addEventListener('tile-visibility-change', this._listeners.onTileVisibilityChanged); this._tiles.addEventListener('dispose-model', this._listeners.onTileDisposed); this._debugPlugin = new DebugTilesPlugin(); this._tiles.registerPlugin(this._debugPlugin); this.updateDebugPluginState(); this._tiles.registerPlugin(new ImplicitTilingPlugin()); this._tiles.registerPlugin(new UnloadTilesPlugin({ delay: 5000, bytesTarget: +Infinity })); // Giro3D specific plugins this._fetchPlugin = new FetchPlugin(); this._pointCloudPlugin = new PointCloudPlugin(this._pointCloudParameters); this._tiles.registerPlugin(this._pointCloudPlugin); this._tiles.registerPlugin(this._fetchPlugin); const dracoLoader = new DRACOLoader(this._tiles.manager).setDecoderPath(options?.dracoDecoderPath ?? `https://unpkg.com/three@0.${REVISION}.0/examples/jsm/libs/draco/gltf/`); const ktxLoader = new KTX2Loader(this._tiles.manager).setTranscoderPath(options?.ktx2DecoderPath ?? `https://unpkg.com/three@0.${REVISION}.0/examples/jsm/libs/basis/`); this._ktx2Loader = ktxLoader; this._tiles.registerPlugin(new GLTFExtensionsPlugin({ dracoLoader, ktxLoader, // FIXME the following parameters are optional but the .d.ts file makes them mandatory // https://github.com/NASA-AMMOS/3DTilesRendererJS/pull/908 metadata: true, rtc: true, autoDispose: true, plugins: [] })); this._pointCloudParameters.pointCloudMode = options?.pointCloudMode ?? this._pointCloudParameters.pointCloudMode; this._pointCloudParameters.pointSize = options?.pointSize ?? this._pointCloudParameters.pointSize; this._pointCloudParameters.pointCloudColorMap = options?.colorMap ?? this._pointCloudParameters.pointCloudColorMap; this._pointCloudParameters.classifications = options?.classifications ?? this._pointCloudParameters.classifications; this._pointCloudParameters.pointCloudColorMap.addEventListener('updated', this._listeners.onColorMapUpdated); } /** * Returns the underlying renderer. */ get tiles() { return this._tiles; } onRenderingContextRestored() { this.forEachLayer(layer => layer.onRenderingContextRestored()); this.instance.notifyChange(this); } getBoundingBox() { const box = new Box3(); this._tiles.getBoundingBox(box); return box; } getMemoryUsage(context) { this.traverse(obj => { if ('geometry' in obj && isBufferGeometry(obj.geometry)) { getGeometryMemoryUsage(context, obj.geometry); } }); if (this.layerCount > 0) { this.forEachLayer(layer => { layer.getMemoryUsage(context); }); } } get loading() { return this.tiles.loadProgress !== 1 || (this._colorLayer?.loading ?? false); } get progress() { let sum = this.tiles.loadProgress; let count = 1; if (this._colorLayer) { sum += this._colorLayer.progress; count = 2; } return sum / count; } updateObjectOption(key, value) { if (this._objectOptions[key] !== value) { this._objectOptions[key] = value; this.traverse(o => this.updateObject(o)); this.notifyChange(this); } } /** * Toggles the `.castShadow` property on objects generated by this entity. * * Note: shadow maps require normal attributes on objects. */ get castShadow() { return this._objectOptions.castShadow; } set castShadow(v) { this.updateObjectOption('castShadow', v); } /** * Toggles the `.receiveShadow` property on objects generated by this entity. * * Note: shadow maps require normal attributes on objects. */ get receiveShadow() { return this._objectOptions.receiveShadow; } set receiveShadow(v) { this.updateObjectOption('receiveShadow', v); } getLayers(predicate) { if (this._colorLayer) { if (typeof predicate != 'function' || predicate(this._colorLayer)) { return [this._colorLayer]; } } return []; } forEachLayer(callback) { if (this._colorLayer) { callback(this._colorLayer); } } removeColorLayer() { if (this._colorLayer) { this.dispatchEvent({ type: 'layer-removed', layer: this._colorLayer }); this.traverse(obj => { if (isLayerNode(obj)) { this._colorLayer?.unregisterNode(obj); } }); this._colorLayer = null; } } /** * Sets the color layer used to colorize tiles. * Note: this feature only works with point cloud tiles. */ async setColorLayer(layer) { if (this._colorLayer) { this.removeColorLayer(); } this._colorLayer = layer; await layer.initialize({ instance: this.instance }); this.dispatchEvent({ type: 'layer-removed', layer }); } get layerCount() { if (this._colorLayer) { return 1; } return 0; } updateOpacity() { this.traverseMaterials(material => { this.setMaterialOpacity(material); }); } preprocess(opts) { return new Promise(resolve => { const instance = opts.instance; // Share resources between instances const shared = getSharedResources(instance); if (shared) { this._tiles.lruCache = shared.lruCache; this._tiles.downloadQueue = shared.downloadQueue; this._tiles.parseQueue = shared.parseQueue; } else { const toShare = { lruCache: this._tiles.lruCache, downloadQueue: this._tiles.downloadQueue, parseQueue: this._tiles.parseQueue }; setSharedResources(instance, toShare); } // Preprocessing is done when the root tileset is loaded const listener = () => { this._tiles.removeEventListener('load-content', listener); resolve(); }; this._tiles.addEventListener('load-content', listener); const camera = instance.view.camera; if (this._tiles.hasCamera(camera) === false) { this._tiles.setCamera(camera); this._tiles.setResolutionFromRenderer(camera, instance.renderer); } this._ktx2Loader.detectSupport(instance.renderer); this._tiles.update(); this.notifyChange(this); }); } preUpdate(context) { if (this.frozen || !this.visible) { return null; } const camera = context.view.camera; this._tiles.setResolutionFromRenderer(camera, this.instance.renderer); this._tiles.update(); return null; } postUpdate(context) { if (this.frozen || !this.visible) { return; } this.traverse(obj => { if (obj.visible) { this.updateCameraDistances(context, obj); if ('material' in obj && PointCloudMaterial.isPointCloudMaterial(obj.material)) { this._pointCloudPlugin.updateMaterial(obj.material); if (isLayerNode(obj)) { this.prepareLayerNode(obj); this.forEachLayer(layer => layer.update(context, obj)); } } } }); this.forEachLayer(layer => layer.postUpdate()); } /** * Calculate and set the material opacity, taking into account this entity opacity and the * original opacity of the object. * * @param material - a material belonging to an object of this entity */ setMaterialOpacity(material) { material.opacity = this.opacity * material.userData.originalOpacity; const currentTransparent = material.transparent; material.transparent = material.opacity < 1.0; material.needsUpdate = currentTransparent !== material.transparent; } onColorMapUpdated() { this.traversePointCloudMaterials(m => m.updateUniforms()); } get errorTarget() { return this._tiles.errorTarget; } set errorTarget(v) { if (this._tiles.errorTarget !== v) { this._tiles.errorTarget = v; this.notifyChange(this); } } /** * Gets or sets the size of points. Only applies to point cloud tiles. */ get pointSize() { return this._pointCloudParameters.pointSize; } set pointSize(v) { if (this._pointCloudParameters.pointSize !== v) { this._pointCloudParameters.pointSize = v; this.traversePointCloudMaterials(m => { m.size = v; }); this.notifyChange(this); } } /** * Gets or sets display mode of point clouds. Only applies to point cloud tiles. */ get pointCloudMode() { return this._pointCloudParameters.pointCloudMode; } set pointCloudMode(v) { if (this._pointCloudParameters.pointCloudMode !== v) { this._pointCloudParameters.pointCloudMode = v; this.traversePointCloudMaterials(m => m.mode = v); this.notifyChange(this); } } /** * Gets or sets the default color of point clouds. Only applies to point cloud tiles. */ get pointCloudColor() { return this._pointCloudParameters.overlayColor; } set pointCloudColor(v) { const color = v != null ? new Color(v) : new Color(); if (v == null || this._pointCloudParameters.overlayColor == null || !this._pointCloudParameters.overlayColor.equals(color)) { this._pointCloudParameters.overlayColor = color; this.notifyChange(this); } } /** * Gets or sets the point cloud brightness, contrast and saturation. Only applies to point cloud tiles. */ get pointCloudColorimetryOptions() { return this._pointCloudParameters.colorimetry; } set pointCloudColorimetryOptions(v) { if (this._pointCloudParameters.colorimetry !== v) { this._pointCloudParameters.colorimetry = v; this.traversePointCloudMaterials(m => { m.brightness = v.brightness; m.contrast = v.contrast; m.saturation = v.saturation; }); this.notifyChange(this); } } /** * Gets the classifications for point clouds. Only applies to point cloud tiles. */ get pointCloudClassifications() { return this._pointCloudParameters.classifications; } /** * Gets the colormap used for point clouds. Only applies to point cloud tiles. */ get colorMap() { return this._pointCloudParameters.pointCloudColorMap; } traversePointCloudMaterials(callback) { this.traverseMaterials(m => { if (PointCloudMaterial.isPointCloudMaterial(m)) { callback(m); } }); } setDebugParam(key, value) { // This plugin has a severe performance cost until it can be disabled at runtime // See https://github.com/NASA-AMMOS/3DTilesRendererJS/issues/647 let plugin = this._tiles.getPluginByName('DEBUG_TILES_PLUGIN'); if (plugin == null) { plugin = new DebugTilesPlugin(); this._tiles.registerPlugin(plugin); } if (plugin != null && plugin[key] !== value) { plugin[key] = value; this.notifyChange(this); } this.updateDebugPluginState(); } updateDebugPluginState() { this._debugPlugin.enabled = this._debugOptions.displayBoxBounds || this._debugOptions.displayRegionBounds || this._debugOptions.displaySphereBounds; } /** * Toggles the display of box volumes. */ get displayBoxBounds() { return this._debugOptions.displayBoxBounds; } set displayBoxBounds(v) { if (this._debugOptions.displayBoxBounds !== v) { this._debugOptions.displayBoxBounds = v; this.setDebugParam('displayBoxBounds', v); } } /** * Toggles the display of sphere volumes. */ get displaySphereBounds() { return this._debugOptions.displaySphereBounds; } set displaySphereBounds(v) { if (this._debugOptions.displaySphereBounds !== v) { this._debugOptions.displaySphereBounds = v; this.setDebugParam('displaySphereBounds', v); } } /** * Toggles the display of region volumes. */ get displayRegionBounds() { return this._debugOptions.displayRegionBounds; } set displayRegionBounds(v) { if (this._debugOptions.displayRegionBounds !== v) { this._debugOptions.displayRegionBounds = v; this.setDebugParam('displayRegionBounds', v); } } /** * Prepares the object so that it can receive a color layer. */ prepareLayerNode(node) { if (node.visible && node.userData.extent == null) { const localBox = node.userData.boundingBox; const worldBox = localBox.clone().applyMatrix4(node.matrixWorld); const extent = Extent.fromBox3(this.instance.referenceCrs, worldBox); node.userData.extent = extent; } } onTileDisposed(e) { const { scene } = e; if (this.layerCount !== 0 && isLayerNode(scene)) { this.forEachLayer(layer => layer.unregisterNode(scene)); } this.notifyChange(this); } onTileVisibilityChanged(e) { const { scene, visible } = e; if (this.layerCount !== 0 && isLayerNode(scene)) { if (visible && scene.userData.extent == null) { this.prepareLayerNode(scene); } // We have to unregister the node when the tile becomes invisible // because currently, the library does not delete invisible tiles // See https://github.com/NASA-AMMOS/3DTilesRendererJS/pull/874 // for a future plugin that will actually unload the tiles. if (!visible) { this.forEachLayer(layer => layer.unregisterNode(scene)); } scene.dispatchEvent({ type: 'visibility-changed' }); } if (visible) { this.updateMaterial(scene); } this.notifyChange(this); } updateMaterial(scene) { if (isPNTSScene(scene)) { this._pointCloudPlugin.updateMaterial(scene.material); } } updateObject(obj) { const opts = this._objectOptions; // Note that for object to actually cast/receive shadows, they *must* // have a normal attribute set. This is not really documented anywhere // in the three.js documentation. "flat shading" is not sufficient, // as normals from flat shading are computed directly in the shader, // which is ignored by the actual shader used for shadows. obj.castShadow = opts.castShadow; obj.receiveShadow = opts.receiveShadow; } onModelLoaded(e) { if (typeof e === 'object' && e != null && 'scene' in e && isObject3D(e.scene)) { this.onObjectCreated(e.scene); e.scene.traverse(o => this.updateObject(o)); this.updateMaterial(e.scene); this.notifyChange(this); } } setupMaterial(material) { material.clippingPlanes = this.clippingPlanes; // this object can already be transparent with opacity < 1.0 // we need to honor it, even when we change the whole entity's opacity if (material.userData.originalOpacity == null) { material.userData.originalOpacity = material.opacity; } this.setMaterialOpacity(material); } updateCameraDistances(context, obj) { const plane = context.distance.plane; if (obj.visible && 'geometry' in obj && isBufferGeometry(obj.geometry)) { const geometry = obj.geometry; if (geometry.boundingBox == null) { geometry.computeBoundingBox(); } // Note: this algorithm is exactly the same as the one used by the map // TODO We might want to extract it and commonalize. // https://gitlab.com/giro3d/giro3d/-/issues/540 const bbox = tmpBox3.copy(nonNull(geometry.boundingBox)).applyMatrix4(obj.matrixWorld); const distance = plane.distanceToPoint(bbox.getCenter(tmpVector)); const radius = bbox.getSize(tmpVector).length() * 0.5; this._distance.min = Math.min(this._distance.min, distance - radius); this._distance.max = Math.max(this._distance.max, distance + radius); } } dispose() { this._tiles.removeEventListener('load-model', this._listeners.onModelLoaded); this._tiles.removeEventListener('tile-visibility-change', this._listeners.onTileVisibilityChanged); this._tiles.removeEventListener('dispose-model', this._listeners.onTileDisposed); this._pointCloudParameters.pointCloudColorMap.removeEventListener('updated', this._listeners.onColorMapUpdated); } }