@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
890 lines (747 loc) • 27.8 kB
text/typescript
import type { Side } from 'three';
import {
Box3,
FrontSide,
Matrix4,
Mesh,
MeshBasicMaterial,
Ray,
RGBAFormat,
UnsignedByteType,
Vector2,
Vector3,
type Intersection,
type Object3D,
type Object3DEventMap,
type Raycaster,
type Texture,
type WebGLRenderTarget,
} from 'three';
import { readRGRenderTargetIntoRGBAU8Buffer } from '../renderer/composition/WebGLComposer';
import type LayeredMaterial from '../renderer/LayeredMaterial';
import type { MaterialOptions } from '../renderer/LayeredMaterial';
import MaterialUtils from '../renderer/MaterialUtils';
import MemoryTracker from '../renderer/MemoryTracker';
import type RenderingState from '../renderer/RenderingState';
import type Disposable from './Disposable';
import type Extent from './geographic/Extent';
import type GetElevationOptions from './GetElevationOptions';
import HeightMap from './HeightMap';
import type Instance from './Instance';
import ElevationLayer from './layer/ElevationLayer';
import type Layer from './layer/Layer';
import type MemoryUsage from './MemoryUsage';
import { type GetMemoryUsageContext } from './MemoryUsage';
import OffsetScale from './OffsetScale';
import Rect from './Rect';
import TileGeometry from './TileGeometry';
import { type NeighbourList } from './TileIndex';
import type UniqueOwner from './UniqueOwner';
import { intoUniqueOwner } from './UniqueOwner';
const ray = new Ray();
const inverseMatrix = new Matrix4();
const THIS_RECT = new Rect(0, 1, 0, 1);
const helperMaterial = new MeshBasicMaterial({
color: '#75eba8',
depthTest: false,
depthWrite: false,
wireframe: true,
transparent: true,
});
const NO_NEIGHBOUR = -99;
const NO_OFFSET_SCALE = new OffsetScale(0, 0, 0, 0);
const tempVec2 = new Vector2();
const tempVec3 = new Vector3();
type GeometryPool = Map<string, TileGeometry>;
function makePooledGeometry(pool: GeometryPool, extent: Extent, segments: number, level: number) {
const key = `${segments}-${level}`;
const cached = pool.get(key);
if (cached) {
return cached;
}
const dimensions = extent.dimensions();
const geometry = new TileGeometry({ dimensions, segments });
pool.set(key, geometry);
return geometry;
}
function makeRaycastableGeometry(extent: Extent, segments: number) {
const dimensions = extent.dimensions();
const geometry = new TileGeometry({ dimensions, segments });
return geometry;
}
export interface TileMeshEventMap extends Object3DEventMap {
'visibility-changed': unknown;
dispose: unknown;
}
class TileVolume {
private readonly _localBox: Box3;
private readonly _owner: Object3D<Object3DEventMap>;
constructor(options: { extent: Extent; min: number; max: number; owner: Object3D }) {
const dims = options.extent.dimensions(tempVec2);
const width = dims.x;
const height = dims.y;
const min = new Vector3(-width / 2, -height / 2, options.min);
const max = new Vector3(+width / 2, +height / 2, options.max);
this._localBox = new Box3(min, max);
this._owner = options.owner;
}
get centerZ() {
return this.localBox.getCenter(tempVec3).z;
}
get localBox(): Readonly<Box3> {
return this._localBox;
}
/**
* Gets or set the min altitude, in local coordinates.
*/
get zMin() {
return this._localBox.min.z;
}
set zMin(v: number) {
this._localBox.min.setZ(v);
}
/**
* Gets or set the max altitude, in local coordinates.
*/
get zMax() {
return this._localBox.max.z;
}
set zMax(v: number) {
this._localBox.max.setZ(v);
}
/**
* Returns the local size of this volume.
*/
getLocalSize(target: Vector3): Vector3 {
return this._localBox.getSize(target);
}
/**
* Returns the local bounding box.
*/
getLocalBoundingBox(target?: Box3): Box3 {
const result = target ?? new Box3();
result.copy(this._localBox);
return result;
}
/**
* Gets the world bounding box, taking into account world transformation.
*/
getWorldSpaceBoundingBox(target?: Box3): Box3 {
const result = target ?? new Box3();
result.copy(this._localBox);
this._owner.updateWorldMatrix(true, false);
result.applyMatrix4(this._owner.matrixWorld);
return result;
}
}
class TileMesh
extends Mesh<TileGeometry, LayeredMaterial, TileMeshEventMap>
implements Disposable, MemoryUsage
{
readonly isMemoryUsage = true as const;
private readonly _pool: GeometryPool;
private readonly _extentDimensions: Vector2;
private _segments: number;
readonly type: string = 'TileMesh';
readonly isTileMesh: boolean = true;
private _minmax: { min: number; max: number } = { min: -Infinity, max: +Infinity };
readonly extent: Extent;
readonly textureSize: Vector2;
private readonly _volume: TileVolume;
private _materialSide: Side = FrontSide;
readonly level: number;
readonly x: number;
readonly y: number;
readonly z: number;
private _heightMap: UniqueOwner<HeightMap, this> | null = null;
disposed = false;
private _enableTerrainDeformation: boolean;
private readonly _enableCPUTerrain: boolean;
private readonly _instance: Instance;
private readonly _onElevationChanged: (tile: this) => void;
private _shouldUpdateHeightMap = false;
isLeaf = false;
private _elevationLayerInfo: {
layer: ElevationLayer;
offsetScale: OffsetScale;
renderTarget: WebGLRenderTarget<Texture>;
} | null = null;
private _helperMesh: Mesh<TileGeometry, MeshBasicMaterial, Object3DEventMap> | null = null;
getMemoryUsage(context: GetMemoryUsageContext) {
this.material?.getMemoryUsage(context);
// We only count what we own, otherwise the same heightmap will be counted more than once.
if (this._heightMap && this._heightMap.owner === this) {
context.objects.set(`heightmap-${this._heightMap.owner.id}`, {
cpuMemory: this._heightMap.payload.buffer.byteLength,
gpuMemory: 0,
});
}
// If CPU terrain is enabled, then the geometry is owned by this mesh, rather than
// shared with other meshes in the same map, so we have to count it.
if (this._enableCPUTerrain) {
this.geometry.getMemoryUsage(context);
}
}
get boundingBox(): Box3 {
if (!this._enableTerrainDeformation) {
this._volume.zMin = 0;
this._volume.zMax = 0;
} else {
this._volume.zMin = this.minmax.min;
this._volume.zMax = this.minmax.max;
}
return this._volume.localBox;
}
getWorldSpaceBoundingBox(target: Box3): Box3 {
return this._volume.getWorldSpaceBoundingBox(target);
}
/**
* Creates an instance of TileMesh.
*
* @param options - Constructor options.
*/
constructor({
geometryPool,
material,
extent,
segments,
coord: { level, x = 0, y = 0 },
textureSize,
instance,
enableCPUTerrain,
enableTerrainDeformation,
onElevationChanged,
}: {
/** The geometry pool to use. */
geometryPool: GeometryPool;
/** The tile material. */
material: LayeredMaterial;
/** The tile extent. */
extent: Extent;
/** The subdivisions. */
segments: number;
/** The tile coordinate. */
coord: { level: number; x: number; y: number };
/** The texture size. */
textureSize: Vector2;
instance: Instance;
enableCPUTerrain: boolean;
enableTerrainDeformation: boolean;
onElevationChanged: (tile: TileMesh) => void;
}) {
super(
// CPU terrain forces geometries to be unique, so cannot be pooled
enableCPUTerrain
? makeRaycastableGeometry(extent, segments)
: makePooledGeometry(geometryPool, extent, segments, level),
material,
);
this._pool = geometryPool;
this._segments = segments;
this._instance = instance;
this._onElevationChanged = onElevationChanged;
this.matrixAutoUpdate = false;
this.level = level;
this.extent = extent;
this.textureSize = textureSize;
this._enableCPUTerrain = enableCPUTerrain;
this._enableTerrainDeformation = enableTerrainDeformation;
if (!this.geometry.boundingBox) {
this.geometry.computeBoundingBox();
}
const boundingBox = this.geometry.boundingBox as Box3;
this._volume = new TileVolume({
extent,
owner: this,
min: boundingBox.min.z,
max: boundingBox.max.z,
});
this.name = `tile @ (z=${level}, x=${x}, y=${y})`;
this.frustumCulled = false;
// Layer
this.setDisplayed(false);
this.material.setUuid(this.id);
const dim = extent.dimensions();
this._extentDimensions = dim;
this.material.uniforms.tileDimensions.value.set(dim.x, dim.y);
// Sets the default bbox volume
this.setBBoxZ(-0.5, +0.5);
this.x = x;
this.y = y;
this.z = level;
MemoryTracker.track(this, this.name);
}
get showHelpers() {
if (!this._helperMesh) {
return false;
}
return this._helperMesh.material.visible;
}
set showHelpers(visible: boolean) {
if (visible && !this._helperMesh) {
this._helperMesh = new Mesh(this.geometry, helperMaterial);
this._helperMesh.matrixAutoUpdate = false;
this._helperMesh.name = 'collider helper';
this.add(this._helperMesh);
this._helperMesh.updateMatrix();
this._helperMesh.updateMatrixWorld(true);
}
if (!visible && this._helperMesh) {
this._helperMesh.removeFromParent();
this._helperMesh = null;
}
if (this._helperMesh) {
this._helperMesh.material.visible = visible;
}
}
get segments() {
return this._segments;
}
set segments(v) {
if (this._segments !== v) {
this._segments = v;
this.createGeometry();
this.material.segments = v;
if (this._enableCPUTerrain) {
this._shouldUpdateHeightMap = true;
}
}
}
private createGeometry() {
this.geometry = this._enableCPUTerrain
? makeRaycastableGeometry(this.extent, this._segments)
: makePooledGeometry(this._pool, this.extent, this._segments, this.level);
if (this._helperMesh) {
this._helperMesh.geometry = this.geometry;
}
}
onLayerVisibilityChanged(layer: Layer) {
if (layer instanceof ElevationLayer && this._enableCPUTerrain) {
this._shouldUpdateHeightMap = true;
}
}
addChildTile(tile: TileMesh) {
this.add(tile);
if (this._heightMap) {
const heightMap = this._heightMap.payload;
const inheritedHeightMap = heightMap.clone();
const offsetScale = tile.extent.offsetToParent(this.extent);
heightMap.offsetScale.combine(offsetScale, inheritedHeightMap.offsetScale);
tile.inheritHeightMap(intoUniqueOwner(inheritedHeightMap, this));
}
}
reorderLayers() {
this.material.reorderLayers();
}
/**
* Checks that the given raycaster intersects with this tile's volume.
*/
private checkRayVolumeIntersection(raycaster: Raycaster): boolean {
const matrixWorld = this.matrixWorld;
// convert ray to local space of mesh
inverseMatrix.copy(matrixWorld).invert();
ray.copy(raycaster.ray).applyMatrix4(inverseMatrix);
// test with bounding box in local space
// Note that we are not using the bounding box of the geometry, because at this moment,
// the mesh might still be completely flat, as the heightmap might not be computed yet.
// This is the whole point of this method: to avoid computing the heightmap if not necessary.
// So we are using the logical bounding box provided by the volume.
return ray.intersectsBox(this.boundingBox);
}
override raycast(raycaster: Raycaster, intersects: Intersection[]): void {
// Updating the heightmap is quite costly operation that requires a texture readback.
// Let's do it only if the ray intersects the volume of this tile.
if (this.checkRayVolumeIntersection(raycaster)) {
this.updateHeightMapIfNecessary();
super.raycast(raycaster, intersects);
}
}
private saveMaterialProperties() {
// Some shadow map rendering forces backside on the material. Since we are not using
// a distinct material for shadows, we need to reset the side to the correct value after
// shadows are rendered.
this._materialSide = this.material.side;
}
private restoreMaterialProperties() {
// Reset shadow map specific defines
this.material.isMeshDistanceMaterial = false;
MaterialUtils.setDefine(this.material, 'DEPTH_RENDER', false);
MaterialUtils.setDefine(this.material, 'DISTANCE_RENDER', false);
MaterialUtils.setDefine(this.material, 'COLOR_RENDER', true);
this.material.side = this._materialSide;
}
// @ts-expect-error customDepthMaterial is supposed to be a property
get customDepthMaterial() {
this.saveMaterialProperties();
// Instead of using a different material, which would have to be synchronized with
// the main material, we simply use the main material and set it to depth render.
MaterialUtils.setDefine(this.material, 'COLOR_RENDER', false);
MaterialUtils.setDefine(this.material, 'DEPTH_RENDER', true);
this.material.onBeforeRender();
return this.material;
}
// @ts-expect-error customDistanceMaterial is supposed to be a property
get customDistanceMaterial() {
this.saveMaterialProperties();
this.material.isMeshDistanceMaterial = true;
// Instead of using a different material, which would have to be synchronized with
// the main material, we simply use the main material and set it to distance render.
MaterialUtils.setDefine(this.material, 'COLOR_RENDER', false);
MaterialUtils.setDefine(this.material, 'DISTANCE_RENDER', true);
this.material.onBeforeRender();
return this.material;
}
onAfterShadow(): void {
this.restoreMaterialProperties();
}
private updateHeightMapIfNecessary(): void {
if (this._shouldUpdateHeightMap && this._enableCPUTerrain) {
this._shouldUpdateHeightMap = false;
if (this._elevationLayerInfo) {
this.createHeightMap(
this._elevationLayerInfo.renderTarget,
this._elevationLayerInfo.offsetScale,
);
const shouldHeightmapBeActive =
this._elevationLayerInfo.layer.visible && this._enableTerrainDeformation;
if (shouldHeightmapBeActive) {
this.applyHeightMap();
} else {
this.resetHeights();
}
}
}
}
/**
* @param neighbour - The neighbour.
* @param location - Its location in the neighbour array.
*/
private processNeighbour(neighbour: TileMesh, location: number) {
const diff = neighbour.level - this.level;
const neighbourTexture = neighbour.material.getElevationTexture();
const neighbourOffsetScale = neighbour.material.getElevationOffsetScale();
const offsetScale = this.extent.offsetToParent(neighbour.extent);
const nOffsetScale = neighbourOffsetScale.combine(offsetScale);
this.material.updateNeighbour(location, diff, nOffsetScale, neighbourTexture);
}
/**
* @param neighbours - The neighbours.
*/
processNeighbours(neighbours: NeighbourList<TileMesh>) {
for (let i = 0; i < neighbours.length; i++) {
const neighbour = neighbours[i];
if (neighbour != null && neighbour.material != null && neighbour.material.visible) {
this.processNeighbour(neighbour, i);
} else {
this.material.updateNeighbour(i, NO_NEIGHBOUR, NO_OFFSET_SCALE, null);
}
}
}
update(materialOptions: MaterialOptions) {
if (this._enableCPUTerrain && this._heightMap && this._elevationLayerInfo) {
if (this._enableTerrainDeformation !== materialOptions.terrain.enabled) {
this._enableTerrainDeformation = materialOptions.terrain.enabled;
this._shouldUpdateHeightMap = true;
}
}
this.showHelpers = materialOptions.showColliderMeshes ?? false;
}
isVisible() {
return this.visible;
}
setDisplayed(show: boolean) {
const currentVisibility = this.material.visible;
this.material.visible = show && this.material.update();
if (this._helperMesh) {
this._helperMesh.visible = this.material.visible;
}
if (currentVisibility !== show) {
this.dispatchEvent({ type: 'visibility-changed' });
}
}
/**
* @param v - The new opacity.
*/
set opacity(v: number) {
this.material.opacity = v;
}
setVisibility(show: boolean) {
const currentVisibility = this.visible;
this.visible = show;
if (currentVisibility !== show) {
this.dispatchEvent({ type: 'visibility-changed' });
}
}
isDisplayed() {
return this.material.visible;
}
/**
* Updates the rendering state of the tile's material.
*
* @param state - The new rendering state.
*/
changeState(state: RenderingState) {
this.material.changeState(state);
}
static applyChangeState(o: Object3D, s: RenderingState) {
if ((o as TileMesh).isTileMesh) {
(o as TileMesh).changeState(s);
}
}
pushRenderState(state: RenderingState) {
if (this.material.uniforms.renderingState.value === state) {
return () => {
/** do nothing */
};
}
const oldState = this.material.uniforms.renderingState.value;
this.traverse(n => TileMesh.applyChangeState(n, state));
return () => {
this.traverse(n => TileMesh.applyChangeState(n, oldState));
};
}
canProcessColorLayer(): boolean {
return this.material.canProcessColorLayer();
}
private static canSubdivideTile(tile: TileMesh): boolean {
let current = tile;
let ancestorLevel = 0;
// To be able to subdivide a tile, we need to ensure that we
// have proper elevation data on this tile (if applicable).
// Otherwise the newly created tiles will not have a correct bounding box,
// and this will mess with frustum culling / level of detail selection, in turn leading
// to dangerous levels of subdivisions (and hundreds/thousands of undesired tiles).
// On the other hand, we can afford a bit of undesired tiles if it means that
// the color layers will display correctly.
const LOD_MARGIN = 3;
while (ancestorLevel < LOD_MARGIN && current != null) {
if (
current != null &&
current.material != null &&
current.material.isElevationLayerTextureLoaded()
) {
return true;
}
ancestorLevel++;
if (isTileMesh(current.parent)) {
current = current.parent as TileMesh;
} else {
break;
}
}
return false;
}
canSubdivide() {
return TileMesh.canSubdivideTile(this);
}
removeElevationTexture() {
this._elevationLayerInfo = null;
this._shouldUpdateHeightMap = true;
this.material.removeElevationLayer();
}
setElevationTexture(
layer: ElevationLayer,
elevation: {
texture: Texture;
pitch: OffsetScale;
min?: number;
max?: number;
renderTarget: WebGLRenderTarget;
},
isFinal = false,
) {
if (this.disposed) {
return;
}
this._elevationLayerInfo = {
layer,
offsetScale: elevation.pitch,
renderTarget: elevation.renderTarget,
};
this.material.setElevationTexture(layer, elevation, isFinal);
this.setBBoxZ(elevation.min, elevation.max);
if (this._enableCPUTerrain) {
this._shouldUpdateHeightMap = true;
}
this._onElevationChanged(this);
}
private createHeightMap(renderTarget: WebGLRenderTarget, offsetScale: OffsetScale) {
const outputHeight = Math.floor(renderTarget.height);
const outputWidth = Math.floor(renderTarget.width);
// One millimeter
const precision = 0.001;
// To ensure that all values are positive before encoding
const offset = -this._minmax.min;
const buffer = readRGRenderTargetIntoRGBAU8Buffer({
renderTarget,
renderer: this._instance.renderer,
outputWidth,
outputHeight,
precision,
offset,
});
const heightMap = new HeightMap(
buffer,
outputWidth,
outputHeight,
offsetScale,
RGBAFormat,
UnsignedByteType,
precision,
offset,
);
this._heightMap = intoUniqueOwner(heightMap, this);
}
private inheritHeightMap(heightMap: UniqueOwner<HeightMap, this>) {
this._heightMap = heightMap;
this._shouldUpdateHeightMap = true;
// Let's get a more precise minmax from the inherited heightmap, but
// only on the region of the inherited heightmap that matches this tile's extent
// (otherwise this would not provide any benefit at all);
const minmax = heightMap.payload.getMinMax(THIS_RECT);
if (minmax != null) {
this._minmax = minmax;
}
}
private resetHeights() {
this.geometry.resetHeights();
this.setBBoxZ(0, 0);
this._onElevationChanged(this);
}
private applyHeightMap() {
if (!this._heightMap) {
return;
}
const { min, max } = this.geometry.applyHeightMap(this._heightMap.payload);
if (min > this._minmax.min && max < this._minmax.max) {
this.setBBoxZ(min, max);
}
this._onElevationChanged(this);
}
setBBoxZ(min: number | undefined, max: number | undefined) {
// 0 is an acceptable value
if (min == null || max == null) {
return;
}
this._minmax = { min, max };
this.updateVolume(min, max);
}
traverseTiles(callback: (descendant: TileMesh) => void) {
this.traverse(obj => {
if (isTileMesh(obj)) {
callback(obj);
}
});
}
/**
* Removes the child tiles and returns the detached tiles.
*/
detachChildren(): TileMesh[] {
const childTiles = this.children.filter(c => isTileMesh(c)) as TileMesh[];
childTiles.forEach(c => c.dispose());
this.remove(...childTiles);
return childTiles;
}
private updateVolume(min: number, max: number) {
const v = this._volume;
if (Math.floor(min) !== Math.floor(v.zMin) || Math.floor(max) !== Math.floor(v.zMax)) {
this._volume.zMin = min;
this._volume.zMax = max;
}
}
get minmax() {
const range = Math.abs(this._minmax.max - this._minmax.min);
const width = this._extentDimensions.width;
const height = this._extentDimensions.height;
const RATIO = 3;
// If the current volume is very elongated in the vertical axis,
// this can cause excessive subdivisions of the tile. Let's compute
// the heightmap to get a more precise min/max and hopefully a tighter
// volume. Note that the heightmap will be computed only if it does not
// exist, avoiding unnecessary computations.
if (range / Math.max(width, height) > RATIO) {
this.updateHeightMapIfNecessary();
}
return this._minmax;
}
getExtent() {
return this.extent;
}
getElevation(params: GetElevationOptions): { elevation: number; resolution: number } | null {
this.updateHeightMapIfNecessary();
if (this._heightMap) {
const uv = this.extent.offsetInExtent(params.coordinates, tempVec2);
const heightMap = this._heightMap.payload;
const elevation = heightMap.getValue(uv.x, uv.y);
if (elevation != null) {
const dims = this.extent.dimensions(tempVec2);
const xRes = dims.x / heightMap.width;
const yRes = dims.y / heightMap.height;
const resolution = Math.min(xRes, yRes);
return { elevation, resolution };
}
}
return null;
}
/**
* Gets whether this mesh is currently performing processing.
*
* @returns `true` if the mesh is currently performing processing, `false` otherwise.
*/
get loading() {
return this.material.loading;
}
/**
* Gets the progress percentage (normalized in [0, 1] range) of the processing.
*
* @returns The progress percentage.
*/
get progress() {
return this.material.progress;
}
/**
* Search for a common ancestor between this tile and another one. It goes
* through parents on each side until one is found.
*
* @param tile - the tile to evaluate
* @returns the resulting common ancestor
*/
findCommonAncestor(tile: TileMesh): TileMesh | null {
if (tile == null) {
return null;
}
if (tile.level === this.level) {
if (tile.id === this.id) {
return tile;
}
if (tile.level !== 0) {
return (this.parent as TileMesh).findCommonAncestor(tile.parent as TileMesh);
}
return null;
}
if (tile.level < this.level) {
return (this.parent as TileMesh).findCommonAncestor(tile);
}
return this.findCommonAncestor(tile.parent as TileMesh);
}
isAncestorOf(node: TileMesh) {
return node.findCommonAncestor(this) === this;
}
dispose() {
if (this.disposed) {
return;
}
this.disposed = true;
this.dispatchEvent({ type: 'dispose' });
this.material.dispose();
if (this._enableCPUTerrain) {
// When colliders are enabled, geometries are created for each tile,
// and thus must be disposed when the mesh is disposed.
this.geometry.dispose();
}
}
}
export function isTileMesh(o: unknown): o is TileMesh {
return (o as TileMesh).isTileMesh;
}
export default TileMesh;