@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
873 lines (762 loc) • 28.2 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import {
type CanvasTexture,
type DataTexture,
type MagnificationTextureFilter,
type Material,
Mesh,
type MinificationTextureFilter,
type PixelFormat,
PlaneGeometry,
type Texture,
type TextureDataType,
Vector2,
type WebGLRenderer,
type WebGLRenderTarget,
} from 'three';
import type Extent from '../geographic/Extent';
import type MemoryUsage from '../MemoryUsage';
import WebGLComposer, { type DrawOptions } from '../../renderer/composition/WebGLComposer';
import { isEmptyTexture } from '../../renderer/EmptyTexture';
import MemoryTracker from '../../renderer/MemoryTracker';
import { isFiniteNumber } from '../../utils/predicates';
import ProjUtils from '../../utils/ProjUtils';
import TextureGenerator from '../../utils/TextureGenerator';
import { nonNull } from '../../utils/tsutils';
import Coordinates from '../geographic/Coordinates';
import CoordinateSystem from '../geographic/CoordinateSystem';
import { type GetMemoryUsageContext, type MemoryUsageReport } from '../MemoryUsage';
import Rect from '../Rect';
import Interpretation from './Interpretation';
const tmpVec1 = new Vector2();
const tmpVec2 = new Vector2();
const DEFAULT_WARP_SUBDIVISIONS = 8;
const tmpFloat64 = new Float64Array(DEFAULT_WARP_SUBDIVISIONS * DEFAULT_WARP_SUBDIVISIONS * 3);
const tmpCoords = new Coordinates(CoordinateSystem.epsg4326, 0, 0);
/**
* Removes the texture data from CPU memory.
* Important: this should only be done **after** the texture has been uploaded to the GPU.
*
* @param texture - The texture to purge.
*/
function onTextureUploaded(texture: Texture): void {
// The texture is empty.
if (texture.image == null) {
return;
}
if ((texture as DataTexture).isDataTexture) {
texture.image.data = null;
} else if ((texture as CanvasTexture).isCanvasTexture) {
texture.source.data = null;
}
}
interface TextureWithMinMax extends Texture {
min?: number;
max?: number;
}
/**
* @param texture - The texture to process.
* @param options - Options.
*/
function processMinMax(
texture: TextureWithMinMax,
{
interpretation,
noDataValue,
}: {
/** The interpretation. */
interpretation: Interpretation;
/** The no-data value. */
noDataValue: number;
},
): { min: number; max: number } {
if (texture.min != null && texture.max != null) {
return { min: texture.min, max: texture.max };
}
const result = TextureGenerator.computeMinMax(texture, noDataValue, interpretation);
if (!result) {
throw new Error('no min/max could be computed from texture');
} else {
return result;
}
}
const tmpMemoryUsageMap = new Map<string | number, MemoryUsageReport>();
class Image implements MemoryUsage {
public readonly isMemoryUsage = true as const;
public readonly id: string;
public readonly mesh: Mesh;
public readonly extent: Extent;
public readonly texture: Texture;
public readonly alwaysVisible: boolean;
public readonly material: Material;
public readonly min?: number;
public readonly max?: number;
public disposed: boolean;
public readonly owners: Set<number>;
public getMemoryUsage(context: GetMemoryUsageContext): void {
return TextureGenerator.getMemoryUsage(context, this.texture);
}
public constructor(options: {
id: string;
mesh: Mesh;
texture: Texture;
extent: Extent;
alwaysVisible: boolean;
min?: number;
max?: number;
}) {
this.id = options.id;
this.mesh = options.mesh;
this.extent = options.extent;
this.texture = options.texture;
this.alwaysVisible = options.alwaysVisible ?? false;
this.material = this.mesh.material as Material;
this.min = options.min;
this.max = options.max;
this.disposed = false;
this.owners = new Set();
}
public canBeDeleted(): boolean {
return !this.alwaysVisible && this.owners.size === 0;
}
public set visible(v: boolean) {
this.mesh.visible = v;
}
public get visible(): boolean {
return this.mesh.visible;
}
public set opacity(v: number) {
this.material.opacity = v;
}
public get opacity(): number {
return this.material.opacity;
}
public dispose(): void {
if (this.disposed) {
throw new Error('already disposed');
}
this.disposed = true;
this.texture?.dispose();
}
}
class LayerComposer implements MemoryUsage {
public readonly isMemoryUsage = true as const;
public readonly computeMinMax: boolean;
public readonly extent?: Extent;
public readonly dimensions: Vector2 | null;
public readonly images: Map<string, Image>;
public readonly webGLRenderer: WebGLRenderer;
public readonly transparent: boolean;
public readonly noDataValue: number;
public readonly sourceCrs: CoordinateSystem;
public readonly targetCrs: CoordinateSystem;
public readonly needsReprojection: boolean;
public readonly interpretation: Interpretation;
public readonly composer: WebGLComposer;
public readonly fillNoData: boolean;
public readonly fillNoDataAlphaReplacement?: number;
public readonly fillNoDataRadius?: number;
public readonly pixelFormat: PixelFormat;
public readonly textureDataType: TextureDataType;
public readonly showEmptyTextures: boolean;
private readonly _minFilter?: MinificationTextureFilter;
private readonly _magFilter?: MagnificationTextureFilter;
private _needsCleanup: boolean;
public getMemoryUsage(context: GetMemoryUsageContext): void {
this.images.forEach(img => img.getMemoryUsage(context));
}
/**
* @param options - The options.
*/
public constructor(options: {
/** The WebGLRenderer. */
renderer: WebGLRenderer;
/** Compute min/max on generated images. */
computeMinMax: boolean;
/** Enables transparency. */
transparent?: boolean;
/** The no-data value. */
noDataValue?: number;
/** The CRS of the source. */
sourceCrs: CoordinateSystem;
/** The extent. */
extent?: Extent;
/** Show image outlines. */
showImageOutlines: boolean;
/** The target CRS of this composer. */
targetCrs: CoordinateSystem;
/** The interpretation of the layer. */
interpretation: Interpretation;
/** Fill no-data values of the image. */
fillNoData: boolean;
/** Alpha value for no-data pixels (after replacement) */
fillNoDataAlphaReplacement?: number;
/** Fill no-data maximum radius. */
fillNoDataRadius?: number;
/** The pixel format of the output textures. */
pixelFormat: PixelFormat;
/** The type of the output textures. */
textureDataType: TextureDataType;
/** Shows empty textures as colored rectangles */
showEmptyTextures: boolean;
/** The dimensions of the extent to use for z-index computation */
dimensions?: Vector2;
minFilter?: MinificationTextureFilter;
magFilter?: MagnificationTextureFilter;
}) {
this.computeMinMax = options.computeMinMax;
this.extent = options.extent;
this.dimensions = options.dimensions ?? null;
this.images = new Map();
this.webGLRenderer = options.renderer;
this.transparent = options.transparent ?? false;
this.noDataValue = options.noDataValue ?? 0;
this.sourceCrs = options.sourceCrs;
this.targetCrs = options.targetCrs;
this.needsReprojection = !this.sourceCrs.equals(this.targetCrs);
this.interpretation = options.interpretation;
this.fillNoData = options.fillNoData;
this.fillNoDataAlphaReplacement = options.fillNoDataAlphaReplacement;
this.fillNoDataRadius = options.fillNoDataRadius;
this.pixelFormat = options.pixelFormat;
this.textureDataType = options.textureDataType;
this.showEmptyTextures = options.showEmptyTextures;
this._minFilter = options.minFilter;
this._magFilter = options.magFilter;
this.composer = new WebGLComposer({
webGLRenderer: options.renderer,
extent: this.extent ? Rect.fromExtent(this.extent) : undefined,
showImageOutlines: options.showImageOutlines,
minFilter: this._minFilter,
magFilter: this._magFilter,
pixelFormat: options.pixelFormat,
textureDataType: options.textureDataType,
showEmptyTextures: options.showEmptyTextures,
});
this._needsCleanup = false;
}
/**
* Prevents the specified image from being removed during the cleanup step.
*
* @param id - The image ID to lock.
* @param nodeId - The node id.
*/
public lock(id: string, nodeId: number): void {
const img = this.images.get(id);
if (img) {
img.owners.add(nodeId);
}
}
/**
* Allows the specified images to be removed during the cleanup step.
*
* @param ids - The image id to unlock.
* @param nodeId - The node id.
*/
public unlock(ids: Set<string>, nodeId: number): void {
ids.forEach(id => {
const image = this.images.get(id);
if (image) {
image.owners.delete(nodeId);
if (image.owners.size === 0) {
this._needsCleanup = true;
}
}
});
}
/**
* Computes the render order for an image that has the specified extent.
*
* Smaller images will be rendered on top of bigger images.
*
* @param extent - The extent.
* @returns The render order to use for the specified extent.
*/
private computeRenderOrder(extent: Extent): number {
if (this.dimensions) {
const width = extent.dimensions(tmpVec2).width;
// The smaller the image, the bigger its render order will be.
const ratio = this.dimensions.width / width;
const result = ratio * 1_000;
return Math.round(result);
}
return 0;
}
private preprocessImage(
extent: Extent,
texture: TextureWithMinMax,
options: {
fillNoData?: boolean;
interpretation: Interpretation;
fillNoDataAlphaReplacement?: number;
fillNoDataRadius?: number;
outputType: TextureDataType;
target?: WebGLRenderTarget<Texture>;
expandRGB?: boolean;
},
): TextureWithMinMax {
const rect = Rect.fromExtent(extent);
const comp = new WebGLComposer({
extent: rect,
width: texture.image.width,
height: texture.image.height,
webGLRenderer: this.webGLRenderer,
textureDataType: options.outputType,
pixelFormat: this.pixelFormat,
expandRGB: options.expandRGB ?? false,
});
// The fill no-data radius is expressed in CRS units in the API,
// but in UV space in the shader. A conversion is necessary.
let noDataRadiusInUVSpace = 1; // Default is no limit.
if (
options.fillNoData === true &&
options.fillNoDataRadius != null &&
Number.isFinite(options.fillNoDataRadius)
) {
const dims = extent.dimensions(tmpVec2);
noDataRadiusInUVSpace = options.fillNoDataRadius / dims.width;
}
comp.draw(texture, rect, {
fillNoData: options.fillNoData,
fillNoDataAlphaReplacement: options.fillNoDataAlphaReplacement,
fillNoDataRadius: noDataRadiusInUVSpace,
interpretation: options.interpretation,
transparent: this.transparent,
});
const result = comp.render({
target: options.target,
}) as TextureWithMinMax;
result.name = 'LayerComposer - image (preprocessed)';
result.min = texture.min;
result.max = texture.max;
comp.dispose();
texture.dispose();
return result;
}
/**
* Creates a lattice mesh whose each vertex has been warped to the target CRS.
*
* @param sourceExtent - The source extent of the mesh to reproject, in the CRS of the source.
* @param segments - The number of subdivisions of the lattice.
* A high value will create more faithful reprojections, at the cost of performance.
*/
private createWarpedMesh(
sourceExtent: Extent,
segments: number = DEFAULT_WARP_SUBDIVISIONS,
): Mesh {
const dims = sourceExtent.dimensions(tmpVec1);
// Vector3
const itemSize = 3;
const arraySize = (segments + 1) * (segments + 1) * itemSize;
const float64 = tmpFloat64.length === arraySize ? tmpFloat64 : new Float64Array(arraySize);
const grid = sourceExtent.toGrid(segments, segments, float64, itemSize);
const center = sourceExtent.center(tmpCoords).as(this.targetCrs).toVector2(tmpVec2);
const offset = center.clone().negate();
// Transformations must occur in double precision
ProjUtils.transformBufferInPlace(grid, {
srcCrs: this.sourceCrs,
dstCrs: this.targetCrs,
offset,
stride: itemSize,
});
const geometry = new PlaneGeometry(dims.x, dims.y, segments, segments);
geometry.name = 'warped mesh';
const positionAttribute = geometry.getAttribute('position');
// But vertex buffers are in single precision.
const float32 = positionAttribute.array;
for (let i = 0; i < float64.length; i++) {
float32[i] = float64[i];
}
positionAttribute.needsUpdate = true;
geometry.computeBoundingSphere();
// Note: the material will be set by the WebGLComposer itself.
const result = new Mesh(geometry);
result.position.set(center.x, center.y, 0);
result.updateMatrixWorld();
return result;
}
/**
* Adds a texture into the composer space.
*
* @param options - opts
*/
public add(options: {
/** The image ID. */
id: string;
/** The texture. */
texture: Texture;
/** The geographic extent of the texture. */
extent: Extent;
/** Flip the image vertically. */
flipY?: boolean;
/** The min value of the texture. */
min?: number;
/** The max value of the texture. */
max?: number;
/** Force constant visibility of this image. */
alwaysVisible?: boolean;
/** Optional z-index to apply to the image. */
zIndex?: number;
}): void {
const { extent, texture, id } = options;
if (this.images.has(id)) {
// We already have this image.
return;
}
if (texture == null) {
throw new Error(
'texture cannot be null. Use an empty texture instead. (i.e new EmptyTexture())',
);
}
if (this._minFilter) {
texture.minFilter = this._minFilter;
}
if (this._magFilter) {
texture.magFilter = this._magFilter;
}
let actualTexture = texture;
const expandRGB = TextureGenerator.shouldExpandRGB(
actualTexture.format as PixelFormat,
this.pixelFormat,
);
// The texture might be an empty texture, appearing completely transparent.
// Since is has no data, it cannot be preprocessed.
if (!isEmptyTexture(texture)) {
if (this.computeMinMax && options.min == null && options.max == null) {
const { min, max } = processMinMax(texture, {
interpretation: this.interpretation,
noDataValue: this.noDataValue,
});
options.min = min;
options.max = max;
}
if (expandRGB || !this.interpretation.isDefault()) {
actualTexture = this.preprocessImage(extent, texture, {
interpretation: this.interpretation,
outputType: this.textureDataType,
expandRGB,
});
}
}
let mesh: Mesh;
const composerOptions: DrawOptions = {
transparent: this.transparent,
flipY: options.flipY,
renderOrder: options.zIndex ?? this.computeRenderOrder(extent),
};
if (this.needsReprojection) {
// Draw a warped image
const warpedMesh = this.createWarpedMesh(extent);
mesh = this.composer.drawMesh(actualTexture, warpedMesh, composerOptions);
} else {
// Draw a rectangular image
mesh = this.composer.draw(actualTexture, Rect.fromExtent(extent), composerOptions);
}
if (MemoryTracker.enable) {
MemoryTracker.track(actualTexture, `LayerComposer - texture ${id}`);
}
tmpMemoryUsageMap.clear();
TextureGenerator.getMemoryUsage(
{
renderer: this.webGLRenderer,
objects: tmpMemoryUsageMap,
},
actualTexture,
);
const memoryUsage = nonNull(tmpMemoryUsageMap.get(actualTexture.id));
// Since we are deleting the CPU-side data.
memoryUsage.cpuMemory = 0;
actualTexture.userData.memoryUsage = memoryUsage;
// Register a handler to be notified when the original texture has
// been uploaded to the GPU so that we can reclaim the texture data and free memory.
texture.onUpdate = (): void => onTextureUploaded(texture);
if (this._minFilter) {
actualTexture.minFilter = this._minFilter;
}
if (this._magFilter) {
actualTexture.magFilter = this._magFilter;
}
const image = new Image({
id,
mesh,
texture: actualTexture,
extent,
alwaysVisible: options.alwaysVisible ?? false,
min: options.min,
max: options.max,
});
this.images.set(id, image);
this._needsCleanup = true;
}
/**
* Gets whether this composer contains the specified image.
*
* @param imageId - The image ID.
* @returns True if the composer contains the image.
*/
public has(imageId: string): boolean {
return this.images.has(imageId);
}
public hasAll(imageIds: Iterable<string>): boolean {
for (const id of imageIds) {
if (!this.has(id)) {
return false;
}
}
return true;
}
/**
* Copies the source texture into the destination texture, taking into account the extent
* of both textures.
*
* @param options - Options.
*/
public copy(options: {
/** The extent of the destination texture. */
targetExtent: Extent;
/** The source render targets. */
source: {
texture: TextureWithMinMax;
extent: Extent;
renderOrder: number;
}[];
/** The destination render target. */
dest: WebGLRenderTarget;
}): void {
const targetExtent = options.targetExtent;
const target = options.dest;
const meshes = [];
let min = +Infinity;
let max = -Infinity;
for (const { texture, extent, renderOrder } of options.source) {
const sourceExtent = extent;
const mesh = this.composer.draw(texture, Rect.fromExtent(sourceExtent), {
renderOrder,
});
meshes.push(mesh);
if (texture.min != null && texture.max != null) {
min = Math.min(min, texture.min);
max = Math.max(max, texture.max);
}
}
// Ensure that other images are not visible: we are only
// interested in the images passed as parameters.
for (const img of this.images.values()) {
img.visible = false;
}
this.composer.render({
rect: Rect.fromExtent(targetExtent),
target,
width: target.width,
height: target.height,
});
const targetTexture = target.texture as TextureWithMinMax;
targetTexture.min = min;
targetTexture.max = max;
for (const mesh of meshes) {
this.composer.remove(mesh);
}
}
/**
* Clears the target texture.
*
* @param options - The options.
*/
public clearTexture(options: {
/** The geographic extent of the region. */
extent: Extent;
/** The width, in pixels of the target texture. */
width: number;
/** The height, in pixels of the target texture. */
height: number;
/** Clears the target texture. */
clear: boolean;
/** The optional render target. */
target: WebGLRenderTarget;
}): void {
const { extent, width, height, target } = options;
this.images.forEach(img => {
img.visible = false;
});
this.composer.render({
width,
height,
rect: Rect.fromExtent(extent),
target,
});
}
/**
* Returns the min/max values for images that overlap the specified extent.
*
* @param extent - The extent.
*/
public getMinMax(extent: Extent): { min: number; max: number } {
let min = +Infinity;
let max = -Infinity;
this.images.forEach(image => {
if (extent.intersectsExtent(image.extent)) {
if (isFiniteNumber(image.min) && isFiniteNumber(image.max)) {
min = Math.min(image.min, min);
max = Math.max(image.max, max);
}
}
});
return { min, max };
}
/**
* Renders a region of the composer space into a texture.
*
* @param options - The options.
*/
public render(options: {
/** The geographic extent of the region. */
extent: Extent;
/** The width, in pixels of the target texture. */
width: number;
/** The height, in pixels of the target texture. */
height: number;
/** Clears the target texture. */
clear?: boolean;
/** The image ids to render. */
imageIds: Set<string>;
/** Fallback mode. */
isFallbackMode?: boolean;
/** The optional render target. */
target: WebGLRenderTarget;
}): { texture: TextureWithMinMax; isLastRender: boolean } {
const { extent, width, height, target, imageIds } = options;
// Do we have all the required images for this tile ?
let allImagesReady = true;
for (const id of imageIds.values()) {
if (!this.images.has(id)) {
allImagesReady = false;
break;
}
}
// To render the requested region, the composer needs to
// find all images that are relevant :
// - images that are explictly requested (with the imageIds option) -or-
// - (fallback mode) images that simply intersect the region
const isFallbackMode = options.isFallbackMode ?? !allImagesReady;
// Is this render the last one to do for this request,
// or will we need more renders in the future ?
const isLastRender = !isFallbackMode;
let min = +Infinity;
let max = -Infinity;
// Set image visibility
for (const image of this.images.values()) {
const isRequired = imageIds.has(image.id);
const isInView = extent.intersectsExtent(image.extent) || image.alwaysVisible;
const isEmpty = isEmptyTexture(image.texture);
image.visible =
(!isEmpty || this.showEmptyTextures) &&
((isFallbackMode && isInView) || isRequired);
// An image should be visible:
// - if it is part of the required images,
// - if no required images are available (fallback mode)
if (image.visible) {
image.opacity = 1;
}
if (this.computeMinMax && isRequired && !isEmpty) {
min = Math.min(nonNull(image.min), min);
max = Math.max(nonNull(image.max), max);
}
}
// We didn't have exact images for this request, so we will need to
// compute an approximate minmax from existing images.
if (
this.computeMinMax &&
isFallbackMode &&
(!isFiniteNumber(min) || !isFiniteNumber(max))
) {
for (const image of this.images.values()) {
if (extent.intersectsExtent(image.extent) && !isEmptyTexture(image.texture)) {
min = Math.min(nonNull(image.min), min);
max = Math.max(nonNull(image.max), max);
}
}
}
// If some post-processing is required, we will render into a temporary texture,
// otherwise we can directly render to the client's target.
let texture = this.composer.render({
width,
height,
rect: Rect.fromExtent(extent),
target: this.fillNoData ? undefined : target,
}) as TextureWithMinMax;
texture.min = min;
texture.max = max;
// Apply nodata filling on the final texture. This was originally done as a pre-processing
// step, but this would lead to artifacts in the case where the image is reprojected.
if (this.fillNoData) {
texture = this.processFillNoData(texture, extent, target);
}
return { texture, isLastRender };
}
private processFillNoData(
texture: TextureWithMinMax,
extent: Extent,
target: WebGLRenderTarget<Texture>,
): TextureWithMinMax {
return this.preprocessImage(extent, texture, {
fillNoData: this.fillNoData,
fillNoDataAlphaReplacement: this.fillNoDataAlphaReplacement,
fillNoDataRadius: this.fillNoDataRadius,
interpretation: Interpretation.Raw,
target,
outputType: this.textureDataType,
});
}
public postUpdate(): boolean {
if (this._needsCleanup) {
this.cleanup();
this._needsCleanup = false;
}
return false;
}
private disposeImage(img: Image): void {
// In the case of reprojection, the mesh's geometry
// is owned by this layer composer.
if (this.needsReprojection) {
img.mesh.geometry.dispose();
}
this.composer.remove(img.mesh);
img.dispose();
this.images.delete(img.id);
}
public cleanup(): void {
// Delete eligible images.
for (const img of Array.from(this.images.values())) {
if (img.canBeDeleted()) {
this.disposeImage(img);
}
}
}
/**
* Clears the composer.
*/
public clear(extent?: Extent): void {
if (extent) {
[...this.images.values()].forEach(img => {
if (img.extent.intersectsExtent(extent)) {
this.disposeImage(img);
}
});
} else {
this.images.forEach(img => this.disposeImage(img));
this.images.clear();
this.composer.clear();
}
}
/**
* Disposes the composer.
*/
public dispose(): void {
this.clear();
}
}
export default LayerComposer;