@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
334 lines (271 loc) • 12.3 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import { Box3, Sphere, Vector2, Vector3, type Camera } from 'three';
import type Context from '../core/Context';
import type HasDefaultPointOfView from '../core/HasDefaultPointOfView';
import type PointOfView from '../core/PointOfView';
import type TerrainOptions from '../core/TerrainOptions';
import type { MapSubdivisionStrategy } from './Map';
import type MapLightingOptions from './MapLightingOptions';
import type { TileGeometryBuilder } from './tiles/TileGeometry';
import type TileMesh from './tiles/TileMesh';
import type TileVolume from './tiles/TileVolume';
import Coordinates from '../core/geographic/Coordinates';
import CoordinateSystem from '../core/geographic/CoordinateSystem';
import Ellipsoid from '../core/geographic/Ellipsoid';
import Extent from '../core/geographic/Extent';
import { isColorLayer } from '../core/layer/ColorLayer';
import { isElevationLayer } from '../core/layer/ElevationLayer';
import ScreenSpaceError from '../core/ScreenSpaceError';
import { computeDistanceToFitSphere, computeZoomToFitSphere } from '../renderer/View';
import { isOrthographicCamera, isPerspectiveCamera } from '../utils/predicates';
import Map, { defaultMapSubdivisionStrategy, type MapOptions } from './Map';
import { MapLightingMode } from './MapLightingOptions';
import EllipsoidTileGeometryBuilder from './tiles/EllipsoidTileGeometryBuilder';
import EllipsoidTileVolume from './tiles/EllipsoidTileVolume';
const tempDims = new Vector2();
const tempWorldPosition = new Vector3();
const tempCameraPosition = new Vector3();
const tmpWGS84Coordinates = new Coordinates(CoordinateSystem.epsg4326, 0, 0);
const horizonSphere = new Sphere();
const boundingSphere = new Sphere();
const tempBox = new Box3();
/**
* Always allow subdivision up to LOD 4, then use the default map strategy for subsequent LODs.
*/
export const defaultGlobeSubdivisionStrategy: MapSubdivisionStrategy = (tile, context) => {
if (context.entity.extent.equals(Extent.WGS84) && tile.lod < 5) {
return context.layers.every(
layer =>
!layer.visible ||
// Terrain is negligible at low LODs.
isElevationLayer(layer) ||
(isColorLayer(layer) && layer.isLoaded(tile.id)),
);
}
// After LOD 5, we have to be much stricter than the Map implementation.
// We have zero tolerance here because of extreme recursion levels when
// zooming in close to mountainous areas, due to the fact that we need to
// have the strictest bounding volumes.
return defaultMapSubdivisionStrategy(tile, context);
};
/**
* Options for Globe terrains.
*/
export type GlobeTerrainOptions = Omit<TerrainOptions, 'stitching'>;
export function computeEllipsoidalImageSize(extent: Extent, ellipsoid: Ellipsoid): Vector2 {
const dims = extent.dimensions(tempDims);
const meridianLength = ellipsoid.getMeridianArcLength(extent.north, extent.south);
const centerLatitude = extent.center(tmpWGS84Coordinates).latitude;
// Since the northern edge of the extent has a different size
// than the southern edge (due to polar distortion), let's select the biggest edge.
// For south hemisphere extent, the biggest edge is the northern one, and vice-versa.
const biggestEdgeLatitude = centerLatitude < 0 ? extent.north : extent.south;
// Let's compute the radius of the parallel at this latitude
const parallelLength = ellipsoid.getParallelArcLength(biggestEdgeLatitude, dims.width);
// Contrary to the version in computeImageSize(), we don't need to swap width and height
// because the meridian length will always be greater or equal to the parallel length.
const ratio = parallelLength / meridianLength;
const baseSize = 512;
return new Vector2(Math.round(baseSize * ratio), baseSize);
}
// Note: we disable the extent because it would not make a lot of sense to have
// sections of globes. However, this would be relatively simple to enable in the future
// if someone asks for this feature.
/**
* Constructor options for the {@link Globe} entity.
*/
export interface GlobeOptions extends Omit<MapOptions, 'extent' | 'terrain'> {
/**
* Which ellipsoid to use.
* @defaultValue {@link Ellipsoid.WGS84}
*/
ellipsoid?: Ellipsoid;
/**
* The terrain options.
*/
terrain?: boolean | Partial<GlobeTerrainOptions>;
}
/**
* Displays a Globe.
*
* The API is mostly identical to the {@link Map} entity.
*
* The globe uses the [ECEF reference frame](https://en.wikipedia.org/wiki/Earth-centered,_Earth-fixed_coordinate_system),
* and the WGS84 spheroid ({@link Ellipsoid.WGS84}) by default.
*
* The 3 axes of the 3D scene are the following:
* - X-axis: the axis that crosses the earth at the (0, 0) geographic position (the intersection
* between the greenwich meridian and the equator)
* - Y-axis: the axis that crosses the earth at the (90, 0) geographic position (the intersection
* between the 90° meridian and the equator).
* - Z-axis: The rotation axis of the earth (south/north axis).
*/
class Globe extends Map {
public readonly isGlobe = true as const;
public override readonly type: string = 'Globe' as const;
private readonly _ellipsoid: Ellipsoid;
private _enableHorizonCulling = true;
private _horizonDistance: number | null = null;
/**
* The ellipsoid used to draw this globe.
*/
public get ellipsoid(): Ellipsoid {
return this._ellipsoid;
}
/**
* Enables or disable horizon culling.
* @defaultValue true
*/
public get horizonCulling(): boolean {
return this._enableHorizonCulling;
}
public set horizonCulling(v: boolean) {
this._enableHorizonCulling = v;
}
public constructor(options?: GlobeOptions) {
super({
subdivisionStrategy: defaultGlobeSubdivisionStrategy,
...options,
extent: Extent.WGS84,
});
this._ellipsoid = options?.ellipsoid ?? Ellipsoid.WGS84;
}
protected override testVisibility(node: TileMesh, context: Context): boolean {
const frustumVisible = super.testVisibility(node, context);
let horizonVisible = true;
// Frustum culling is not sufficient for globe, we also have to cull
// tiles that are in the frustum but at the other side of the world.
if (frustumVisible && this.horizonCulling) {
horizonVisible = this.testHorizonVisibility(node, context);
}
return frustumVisible && horizonVisible;
}
private computeHorizonDistance(camera: Camera): void {
if (this._enableHorizonCulling) {
const cameraPosition = camera.getWorldPosition(tempCameraPosition);
const horizonDistance = this.ellipsoid.getOpticalHorizon(
cameraPosition,
this.object3d.getWorldPosition(tempWorldPosition),
);
this._horizonDistance = horizonDistance;
}
}
public override preUpdate(context: Context, changeSources: Set<unknown>): TileMesh[] {
this.computeHorizonDistance(context.view.camera);
return super.preUpdate(context, changeSources);
}
protected testHorizonVisibility(node: TileMesh, context: Context): boolean {
const cameraPosition = context.view.camera.position;
if (this._horizonDistance != null) {
horizonSphere.set(cameraPosition, this._horizonDistance);
if (!horizonSphere.intersectsBox(node.getWorldSpaceBoundingBox(tempBox))) {
return false;
}
}
return true;
}
protected override shouldSubdivide(context: Context, node: TileMesh): boolean {
if (node.lod >= this.maxSubdivisionLevel) {
return false;
}
// Safety mechanism to avoid subdividing extremely elongated tiles at the poles
// that would lead to hundred or thousands of tiles displayed simultaneously.
// Since pixels are extremely stretched in those places, the quality would not be
// much improved anyway.
if (node.lod > 3 && (node.extent.north === 90 || node.extent.south === -90)) {
return false;
}
const worldSphere = node.getWorldSpaceBoundingSphere(boundingSphere);
const geometricError = worldSphere.radius;
const sse = ScreenSpaceError.computeFromSphere(context.view, worldSphere, geometricError);
const textureSize = Math.min(node.textureSize.x, node.textureSize.y);
if (sse / textureSize > this.subdivisionThreshold) {
return true;
}
return false;
}
protected override getTextureSize(extent: Extent): Vector2 {
// Since globe tiles are curved, their extent dimensions is not the same depending
// on the location of the tile. We must compute a reasonable approximation of
// the width and height of the extent in meters.
return computeEllipsoidalImageSize(extent, this._ellipsoid);
}
protected override getGeometryBuilder(): TileGeometryBuilder {
return new EllipsoidTileGeometryBuilder(
this.ellipsoid,
this.segments,
this.terrain.skirts.enabled ? this.terrain.skirts.depth : null,
);
}
protected override createTileVolume(extent: Extent): TileVolume {
return new EllipsoidTileVolume({
extent,
range: {
min: -1,
max: +1,
},
ellipsoid: this._ellipsoid,
});
}
protected override getTileDimensions(extent: Extent): Vector2 {
// Here again, we have to compute dimensions in meters because degrees
// are not acceptable for shading purposes: computing derivatives for normal
// mapping require that all axes have the same units.
return this._ellipsoid.getExtentDimensions(extent);
}
protected override get isEllipsoidal(): boolean {
return true;
}
protected override getComposerProjection(): CoordinateSystem {
return CoordinateSystem.epsg4326;
}
protected override getDefaultTerrainOptions(): Readonly<TerrainOptions> {
const base = super.getDefaultTerrainOptions();
return {
...base,
stitching: false,
};
}
protected override getDefaultLightingOptions(): Readonly<Required<MapLightingOptions>> {
const base = super.getDefaultLightingOptions();
return {
...base,
// Hillshade does not work in a non-planar setup
mode: MapLightingMode.LightBased,
};
}
/**
* Looks at the center of the globe from the [0°, 0°] geographic coordinate.
*/
public override getDefaultPointOfView(
params: Parameters<HasDefaultPointOfView['getDefaultPointOfView']>[0],
): ReturnType<HasDefaultPointOfView['getDefaultPointOfView']> {
const target = new Vector3(0, 0, 0);
const radius = Math.max(this._ellipsoid.semiMajorAxis, this._ellipsoid.semiMinorAxis) * 1.4;
const origin = new Vector3();
let orthographicZoom = 1;
if (isPerspectiveCamera(params.camera)) {
// Let's fit the globe into the camera field of view.
const distance = computeDistanceToFitSphere(params.camera, radius);
const up = this.ellipsoid.getNormal(0, 0);
origin.addScaledVector(up, distance);
} else if (isOrthographicCamera(params.camera)) {
origin.set(radius * 2, 0, 0);
orthographicZoom = computeZoomToFitSphere(params.camera, radius);
}
this.object3d.updateMatrixWorld(true);
// Since the entity could be translated, we have to apply the matrix.
target.applyMatrix4(this.object3d.matrixWorld);
origin.applyMatrix4(this.object3d.matrixWorld);
const result: PointOfView = { origin, target, orthographicZoom };
return Object.freeze(result);
}
}
export function isGlobe(obj: unknown): obj is Globe {
return (obj as Globe).isGlobe === true;
}
export default Globe;