UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

873 lines (762 loc) 28.2 kB
/* * 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;