@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
806 lines (679 loc) • 27 kB
text/typescript
import type { LRUCache, PriorityQueue, Tile } from '3d-tiles-renderer';
import { TilesRenderer } from '3d-tiles-renderer';
import {
DebugTilesPlugin,
GLTFExtensionsPlugin,
ImplicitTilingPlugin,
UnloadTilesPlugin,
} from '3d-tiles-renderer/plugins';
import type { ColorRepresentation, Material, Object3D } from 'three';
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 type ColorimetryOptions from '../core/ColorimetryOptions';
import { defaultColorimetryOptions } from '../core/ColorimetryOptions';
import ColorMap from '../core/ColorMap';
import type Context from '../core/Context';
import Extent from '../core/geographic/Extent';
import type Instance from '../core/Instance';
import type ColorLayer from '../core/layer/ColorLayer';
import type HasLayers from '../core/layer/HasLayers';
import type Layer from '../core/layer/Layer';
import type { LayerNode } from '../core/layer/Layer';
import { getGeometryMemoryUsage, type GetMemoryUsageContext } from '../core/MemoryUsage';
import type Pickable from '../core/picking/Pickable';
import type { PointsPickResult } from '../core/picking/PickPointsAt';
import type PickResult from '../core/picking/PickResult';
import type {
Classification,
Mode,
Mode as PointCloudMaterialMode,
} from '../renderer/PointCloudMaterial';
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 type PointCloudParameters from './3dtiles/PointCloudParameters';
import PointCloudPlugin, { isPNTSScene } from './3dtiles/PointCloudPlugin';
import type { EntityPreprocessOptions, EntityUserData } from './Entity';
import type { Entity3DEventMap } from './Entity3D';
import Entity3D from './Entity3D';
type Listener<T> = (args: T & object) => void;
/** Options to create a Tiles3D object. */
export type Tiles3DOptions = {
/**
* The URL to the root tileset.
* Might be `undefined` if the URL is provided externally (for example by the `GoogleCloudAuthPlugin`)
*/
url?: string;
/**
* The path to the DRACO library files.
* @defaultValue `'https://unpkg.com/three@0.${REVISION}.0/examples/jsm/libs/draco/gltf/'`
*/
dracoDecoderPath?: string;
/**
* The path to the KTX2 library files.
* @defaultValue `'https://unpkg.com/three@0.${REVISION}.0/examples/jsm/libs/basis/'`
*/
ktx2DecoderPath?: string;
/**
* The display mode for point clouds.
* Note: only applies to point cloud tiles.
* @defaultValue color
*/
pointCloudMode?: Mode;
/**
* The point size for point clouds.
* Note: only applies to point cloud tiles.
* @defaultValue automatic size computation
*/
pointSize?: number;
/**
* The colormap used for point cloud coloring.
* Note: only applies to point cloud tiles.
*/
colorMap?: ColorMap;
/**
* The error target that drives tile subdivision.
* @defaultValue 8
*/
errorTarget?: number;
/**
* The classifications for point clouds.
* Note: only applies to point cloud tiles.
*
* @defaultValue {@link ASPRS_CLASSIFICATIONS}
*/
classifications?: Classification[];
};
const tmpBox3 = new Box3();
const tmpVector = new Vector3();
export function isLayerNode(obj: object): obj is LayerNode {
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}.
*/
export type Tiles3DPickResult = PointsPickResult | PickResult;
export interface Tiles3DEventMap extends Entity3DEventMap {
/** Fires when a layer is added to the entity. */
'layer-added': { layer: Layer };
/** Fires when a layer is removed from the entity. */
'layer-removed': { layer: Layer };
}
type SharedResources = {
downloadQueue: PriorityQueue;
parseQueue: PriorityQueue;
lruCache: LRUCache;
};
const perInstanceSharedResources: Map<Instance, SharedResources> = new Map();
function getSharedResources(instance: Instance): SharedResources | null {
return perInstanceSharedResources.get(instance) ?? null;
}
function setSharedResources(instance: Instance, resources: SharedResources): void {
if (perInstanceSharedResources.has(instance)) {
return;
}
perInstanceSharedResources.set(instance, resources);
instance.addEventListener('dispose', e => perInstanceSharedResources.delete(e.target));
}
type ObjectOptions = {
castShadow: boolean;
receiveShadow: boolean;
};
/**
* 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<UserData extends EntityUserData = EntityUserData>
extends Entity3D<Tiles3DEventMap, UserData>
implements Pickable<Tiles3DPickResult>, HasLayers
{
readonly isPickable = true as const;
readonly hasLayers = true as const;
readonly isTiles3D = true as const;
readonly type = 'Tiles3D';
private readonly _debugPlugin: DebugTilesPlugin;
private readonly _fetchPlugin: FetchPlugin;
private readonly _pointCloudPlugin: PointCloudPlugin;
private readonly _tiles: TilesRenderer;
private readonly _ktx2Loader: KTX2Loader;
private readonly _debugOptions = {
displayBoxBounds: false,
displaySphereBounds: false,
displayRegionBounds: false,
};
// Settings that only applies to point cloud tiles
private readonly _pointCloudParameters: 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()),
};
private readonly _objectOptions: ObjectOptions = {
castShadow: false,
receiveShadow: false,
};
private readonly _listeners: {
onModelLoaded: Listener<object>;
onColorMapUpdated: Listener<unknown>;
onTileVisibilityChanged: Listener<{ scene: Object3D; tile: Tile; visible: boolean }>;
onTileDisposed: Listener<{ scene: Object3D; tile: Tile }>;
};
private _colorLayer: ColorLayer | null = null;
constructor(options?: Tiles3DOptions) {
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(): TilesRenderer {
return this._tiles;
}
onRenderingContextRestored(): void {
this.forEachLayer(layer => layer.onRenderingContextRestored());
this.instance.notifyChange(this);
}
getBoundingBox(): Box3 | null {
const box = new Box3();
this._tiles.getBoundingBox(box);
return box;
}
getMemoryUsage(context: GetMemoryUsageContext) {
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;
}
private updateObjectOption<K extends keyof ObjectOptions>(key: K, value: ObjectOptions[K]) {
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: boolean) {
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: boolean) {
this.updateObjectOption('receiveShadow', v);
}
getLayers(predicate?: (arg0: Layer) => boolean): Layer[] {
if (this._colorLayer) {
if (typeof predicate != 'function' || predicate(this._colorLayer)) {
return [this._colorLayer];
}
}
return [];
}
forEachLayer(callback: (layer: Layer) => void): void {
if (this._colorLayer) {
callback(this._colorLayer);
}
}
removeColorLayer(): void {
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: ColorLayer): Promise<void> {
if (this._colorLayer) {
this.removeColorLayer();
}
this._colorLayer = layer;
await layer.initialize({ instance: this.instance });
this.dispatchEvent({ type: 'layer-removed', layer });
}
get layerCount(): number {
if (this._colorLayer) {
return 1;
}
return 0;
}
updateOpacity() {
this.traverseMaterials(material => {
this.setMaterialOpacity(material);
});
}
protected preprocess(opts: EntityPreprocessOptions): Promise<void> {
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: SharedResources = {
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: Context): unknown[] | null {
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: Context): void {
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
*/
protected setMaterialOpacity(material: Material) {
material.opacity = this.opacity * material.userData.originalOpacity;
const currentTransparent = material.transparent;
material.transparent = material.opacity < 1.0;
material.needsUpdate = currentTransparent !== material.transparent;
}
private onColorMapUpdated() {
this.traversePointCloudMaterials(m => m.updateUniforms());
}
get errorTarget() {
return this._tiles.errorTarget;
}
set errorTarget(v: number) {
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: number) {
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: PointCloudMaterialMode) {
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: ColorRepresentation | null) {
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(): ColorimetryOptions {
return this._pointCloudParameters.colorimetry;
}
set pointCloudColorimetryOptions(v: ColorimetryOptions) {
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;
}
private traversePointCloudMaterials(callback: (m: PointCloudMaterial) => void) {
this.traverseMaterials(m => {
if (PointCloudMaterial.isPointCloudMaterial(m)) {
callback(m);
}
});
}
private setDebugParam<K extends keyof DebugTilesPlugin>(key: K, value: DebugTilesPlugin[K]) {
// 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') as DebugTilesPlugin;
if (plugin == null) {
plugin = new DebugTilesPlugin();
this._tiles.registerPlugin(plugin);
}
if (plugin != null && plugin[key] !== value) {
plugin[key] = value;
this.notifyChange(this);
}
this.updateDebugPluginState();
}
private updateDebugPluginState() {
this._debugPlugin.enabled =
this._debugOptions.displayBoxBounds ||
this._debugOptions.displayRegionBounds ||
this._debugOptions.displaySphereBounds;
}
/**
* Toggles the display of box volumes.
*/
get displayBoxBounds(): boolean {
return this._debugOptions.displayBoxBounds;
}
set displayBoxBounds(v: boolean) {
if (this._debugOptions.displayBoxBounds !== v) {
this._debugOptions.displayBoxBounds = v;
this.setDebugParam('displayBoxBounds', v);
}
}
/**
* Toggles the display of sphere volumes.
*/
get displaySphereBounds(): boolean {
return this._debugOptions.displaySphereBounds;
}
set displaySphereBounds(v: boolean) {
if (this._debugOptions.displaySphereBounds !== v) {
this._debugOptions.displaySphereBounds = v;
this.setDebugParam('displaySphereBounds', v);
}
}
/**
* Toggles the display of region volumes.
*/
get displayRegionBounds(): boolean {
return this._debugOptions.displayRegionBounds;
}
set displayRegionBounds(v: boolean) {
if (this._debugOptions.displayRegionBounds !== v) {
this._debugOptions.displayRegionBounds = v;
this.setDebugParam('displayRegionBounds', v);
}
}
/**
* Prepares the object so that it can receive a color layer.
*/
private prepareLayerNode(node: LayerNode) {
if (node.visible && node.userData.extent == null) {
const localBox = node.userData.boundingBox as Box3;
const worldBox = localBox.clone().applyMatrix4(node.matrixWorld);
const extent = Extent.fromBox3(this.instance.referenceCrs, worldBox);
node.userData.extent = extent;
}
}
private onTileDisposed(e: { scene: Object3D; tile: Tile }) {
const { scene } = e;
if (this.layerCount !== 0 && isLayerNode(scene)) {
this.forEachLayer(layer => layer.unregisterNode(scene));
}
this.notifyChange(this);
}
private onTileVisibilityChanged(e: { scene: Object3D; tile: Tile; visible: boolean }) {
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);
}
private updateMaterial(scene: Object3D) {
if (isPNTSScene(scene)) {
this._pointCloudPlugin.updateMaterial(scene.material as PointCloudMaterial);
}
}
private updateObject(obj: Object3D) {
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;
}
private onModelLoaded(e: unknown) {
if (typeof e === 'object' && e != null && 'scene' in e && isObject3D(e.scene)) {
this.onObjectCreated(e.scene as Object3D);
e.scene.traverse(o => this.updateObject(o));
this.updateMaterial(e.scene);
this.notifyChange(this);
}
}
protected setupMaterial(material: 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);
}
private updateCameraDistances(context: Context, obj: Object3D) {
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(): void {
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,
);
}
}