UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

531 lines (457 loc) 18.7 kB
import { ClampToEdgeWrapping, Color, LinearFilter, MathUtils, Mesh, OrthographicCamera, PlaneGeometry, RGBAFormat, Scene, Texture, UnsignedByteType, Vector4, WebGLRenderTarget, type ColorRepresentation, type MagnificationTextureFilter, type MinificationTextureFilter, type PixelFormat, type TextureDataType, type WebGLRenderer, } from 'three'; import Interpretation from '../../core/layer/Interpretation'; import Rect from '../../core/Rect'; import Capabilities from '../../core/system/Capabilities'; import { isMesh, isTexture } from '../../utils/predicates'; import TextureGenerator from '../../utils/TextureGenerator'; import MemoryTracker from '../MemoryTracker'; import ComposerTileMaterial, { isComposerTileMaterial } from './ComposerTileMaterial'; let SHARED_PLANE_GEOMETRY: PlaneGeometry | null = null; const IMAGE_Z = -10; const textureOwners = new Map<string, WebGLRenderTarget>(); const NEAR = 1; const FAR = 100; const DEFAULT_CLEAR = new Color(0, 0, 0); export type DrawableImage = Texture | HTMLImageElement | HTMLCanvasElement; function processTextureDisposal(event: { target: Texture }) { const texture = event.target; texture.removeEventListener('dispose', processTextureDisposal); const owner = textureOwners.get(texture.uuid); if (owner) { owner.dispose(); textureOwners.delete(texture.uuid); } else { // This should never happen console.error('no owner for ', texture); } } interface SaveState { clearAlpha: number; renderTarget: WebGLRenderTarget | null; scissorTest: boolean; scissor: Vector4; clearColor: Color; viewport: Vector4; } export interface DrawOptions { interpretation?: Interpretation; renderOrder?: number; flipY?: boolean; fillNoData?: boolean; fillNoDataRadius?: number; fillNoDataAlphaReplacement?: number; transparent?: boolean; expandRGB?: boolean; convertRGFloatToRGBAUnsignedByte?: { precision: number; offset: number }; } /** * Composes images together using a three.js scene and an orthographic camera. */ class WebGLComposer { private readonly _showImageOutlines: boolean; private readonly _showEmptyTextures: boolean; private readonly _extent?: Rect; private readonly _renderer: WebGLRenderer; private readonly _reuseTexture: boolean; private readonly _clearColor?: ColorRepresentation; private readonly _minFilter: MinificationTextureFilter; private readonly _magFilter: MagnificationTextureFilter; private readonly _ownedTextures: Texture[]; private readonly _scene: Scene; private readonly _camera: OrthographicCamera; private readonly _expandRGB: boolean; private _renderTarget?: WebGLRenderTarget; readonly dataType: TextureDataType; readonly pixelFormat: PixelFormat; readonly width?: number; readonly height?: number; /** * Creates an instance of WebGLComposer. * * @param options - The options. */ constructor(options: { /** Optional extent of the canvas. If undefined, then the canvas is an infinite plane. */ extent?: Rect; /** The canvas width, in pixels. Ignored if a canvas is provided. */ width?: number; /** The canvas height, in pixels. Ignored if a canvas is provided. */ height?: number; /** If true, yellow image outlines will be drawn on images. */ showImageOutlines?: boolean; /** Shows empty textures as colored rectangles */ showEmptyTextures?: boolean; /** If true, this composer will try to reuse the same texture accross renders. * Note that this may not be always possible if the texture format has to change * due to incompatible images to draw. For example, if the current target has 8-bit pixels, * and a 32-bit texture must be drawn onto the canvas, the underlying target will have to * be recreated in 32-bit format. */ reuseTexture?: boolean; /** The minification filter of the generated texture. Default is `LinearFilter`. */ minFilter?: MinificationTextureFilter; /** The magnification filter of the generated texture. Default is `LinearFilter`. */ magFilter?: MagnificationTextureFilter; /** The WebGL renderer to use. This must be the same renderer as the one used * to display the rendered textures, because WebGL contexts are isolated from each other. */ webGLRenderer: WebGLRenderer; /** The clear (background) color. */ clearColor?: ColorRepresentation; /** The pixel format of the output textures. */ pixelFormat: PixelFormat; /** The data type of the output textures. */ textureDataType: TextureDataType; /** If `true`, textures are considered grayscale and will be expanded * to RGB by copying the R channel into the G and B channels. */ expandRGB?: boolean; }) { this._showImageOutlines = options.showImageOutlines ?? false; this._showEmptyTextures = options.showEmptyTextures ?? false; this._extent = options.extent; this.width = options.width; this.height = options.height; this._renderer = options.webGLRenderer; this._reuseTexture = options.reuseTexture ?? false; this._clearColor = options.clearColor; const defaultFilter = TextureGenerator.getCompatibleTextureFilter( LinearFilter, options.textureDataType, options.webGLRenderer, ); this._minFilter = options.minFilter || defaultFilter; this._magFilter = options.magFilter || defaultFilter; this.dataType = options.textureDataType; this.pixelFormat = options.pixelFormat; this._expandRGB = options.expandRGB ?? false; if (!SHARED_PLANE_GEOMETRY) { SHARED_PLANE_GEOMETRY = new PlaneGeometry(1, 1, 1, 1); MemoryTracker.track(SHARED_PLANE_GEOMETRY, 'WebGLComposer - PlaneGeometry'); } // An array containing textures that this composer has created, to be disposed later. this._ownedTextures = []; this._scene = new Scene(); // Define a camera centered on (0, 0), with its // orthographic size matching size of the extent. this._camera = new OrthographicCamera(); this._camera.near = NEAR; this._camera.far = FAR; if (this._extent) { this.setCameraRect(this._extent); } } /** * Sets the camera frustum to the specified rect. * * @param rect - The rect. */ private setCameraRect(rect: Rect) { const halfWidth = rect.width / 2; const halfHeight = rect.height / 2; this._camera.position.set(rect.centerX, rect.centerY, 0); this._camera.left = -halfWidth; this._camera.right = +halfWidth; this._camera.top = +halfHeight; this._camera.bottom = -halfHeight; this._camera.updateProjectionMatrix(); } private createRenderTarget( type: TextureDataType, format: PixelFormat, width: number, height: number, ) { const result = new WebGLRenderTarget(width, height, { format, anisotropy: Capabilities.getMaxAnisotropy(), magFilter: this._magFilter, minFilter: this._minFilter, type, depthBuffer: false, generateMipmaps: true, }); // Normally, the render target "owns" the texture, and whenever this target // is disposed, the texture is disposed with it. // However, in our case, we cannot rely on this behaviour because the owner is the composer // itself, whose lifetime can be shorter than the texture it created. textureOwners.set(result.texture.uuid, result); result.texture.addEventListener('dispose', processTextureDisposal); result.texture.name = 'WebGLComposer texture'; MemoryTracker.track(result, 'WebGLRenderTarget'); MemoryTracker.track(result.texture, 'WebGLRenderTarget.texture'); return result; } /** * Draws an image to the composer. * * @param image - The image to add. * @param extent - The extent of this texture in the composition space. * @param options - The options. */ draw(image: DrawableImage, extent: Rect, options: DrawOptions = {}) { // @ts-expect-error the material is assigned just after const plane = new Mesh(SHARED_PLANE_GEOMETRY, null); MemoryTracker.track(plane, 'WebGLComposer - mesh'); plane.scale.set(extent.width, extent.height, 1); this._scene.add(plane); const x = extent.centerX; const y = extent.centerY; plane.position.set(x, y, 0); return this.drawMesh(image, plane, options); } /** * Draws a texture on a custom mesh to the composer. * * @param image - The image to add. * @param mesh - The custom mesh. * @param options - Options. */ drawMesh(image: DrawableImage, mesh: Mesh, options: DrawOptions = {}): Mesh { let texture: Texture; if (!isTexture(image)) { texture = new Texture(image as HTMLImageElement); texture.needsUpdate = true; this._ownedTextures.push(texture); MemoryTracker.track(texture, 'WebGLComposer - owned texture'); } else { texture = image as Texture; } TextureGenerator.ensureCompatibility(texture, this._renderer); const interpretation = options.interpretation ?? Interpretation.Raw; const material = ComposerTileMaterial.acquire({ texture, noDataOptions: { enabled: options.fillNoData ?? false, radius: options.fillNoDataRadius, replacementAlpha: options.fillNoDataAlphaReplacement, }, interpretation, flipY: options.flipY ?? false, transparent: options.transparent ?? false, showEmptyTexture: this._showEmptyTextures, showImageOutlines: this._showImageOutlines, expandRGB: options.expandRGB ?? this._expandRGB, convertRGFloatToRGBAUnsignedByte: options.convertRGFloatToRGBAUnsignedByte ?? null, }); MemoryTracker.track(material, 'WebGLComposer - material'); mesh.material = material; mesh.renderOrder = options.renderOrder ?? 0; mesh.position.setZ(IMAGE_Z); this._scene.add(mesh); mesh.updateMatrixWorld(true); mesh.matrixAutoUpdate = false; mesh.matrixWorldAutoUpdate = false; return mesh; } remove(mesh: Mesh) { ComposerTileMaterial.release(mesh.material as ComposerTileMaterial); this._scene.remove(mesh); } /** * Resets the composer to a blank state. */ clear() { this.removeTextures(); this.removeObjects(); } private removeObjects() { this._scene.traverse(obj => { if (isMesh(obj) && isComposerTileMaterial(obj.material)) { ComposerTileMaterial.release(obj.material); } }); this._scene.clear(); } private saveState(): SaveState { return { clearAlpha: this._renderer.getClearAlpha(), renderTarget: this._renderer.getRenderTarget(), scissorTest: this._renderer.getScissorTest(), scissor: this._renderer.getScissor(new Vector4()), clearColor: this._renderer.getClearColor(new Color()), viewport: this._renderer.getViewport(new Vector4()), }; } private restoreState(state: SaveState) { this._renderer.setClearAlpha(state.clearAlpha); this._renderer.setRenderTarget(state.renderTarget); this._renderer.setScissorTest(state.scissorTest); this._renderer.setScissor(state.scissor); this._renderer.setClearColor(state.clearColor, state.clearAlpha); this._renderer.setViewport(state.viewport); } /** * Renders the composer into a texture. * * @param opts - The options. * @returns The texture of the render target. */ render( opts: { /** A custom rect for the camera. */ rect?: Rect; /** The width, in pixels, of the output texture. */ width?: number; /** The height, in pixels, of the output texture. */ height?: number; /** The render target. */ target?: WebGLRenderTarget; } = {}, ): Texture { const width = opts.target?.width ?? opts.width ?? this.width; const height = opts.target?.height ?? opts.height ?? this.height; if (width == null || height == null) { throw new Error( 'this composer does not have preset width/height and none was provided', ); } // Should we reuse the same render target or create a new one ? let target; if (opts.target) { target = opts.target; } else if (!this._reuseTexture) { // We create a new render target for this render target = this.createRenderTarget(this.dataType, this.pixelFormat, width, height); } else { if (!this._renderTarget) { if (this.width == null || this.height == null) { throw new Error('cannot reuse render target without height/width defined '); } this._renderTarget = this.createRenderTarget( this.dataType, this.pixelFormat, this.width, this.height, ); } target = this._renderTarget; } const previousState = this.saveState(); if (this._clearColor != null) { this._renderer.setClearColor(this._clearColor); } else { this._renderer.setClearColor(DEFAULT_CLEAR, 0); } this._renderer.setRenderTarget(target); this._renderer.setViewport(0, 0, target.width, target.height); this._renderer.clear(); const rect = opts.rect ?? this._extent; if (!rect) { throw new Error('no rect provided and no default rect to setup camera'); } this.setCameraRect(rect); // If the requested rectangle is not the same as the extent of this composer, // then it is a partial render. // We need to scissor the output in order to render only the overlap between // the requested extent and the extent of this composer. if (this._extent && opts.rect && !opts.rect.equals(this._extent)) { this._renderer.setScissorTest(true); const intersection = this._extent.getIntersection(opts.rect); const sRect = Rect.getNormalizedRect(intersection, opts.rect); // The pixel margin is necessary to avoid bleeding // when textures use linear interpolation. const pixelMargin = 1; const sx = Math.floor(sRect.x * width - pixelMargin); const sy = Math.floor((1 - sRect.y - sRect.h) * height - pixelMargin); const sw = Math.ceil(sRect.w * width + 2 * pixelMargin); const sh = Math.ceil(sRect.h * height + 2 * pixelMargin); this._renderer.setScissor( MathUtils.clamp(sx, 0, width), MathUtils.clamp(sy, 0, height), MathUtils.clamp(sw, 0, width), MathUtils.clamp(sh, 0, height), ); } this._renderer.render(this._scene, this._camera); target.texture.wrapS = ClampToEdgeWrapping; target.texture.wrapT = ClampToEdgeWrapping; target.texture.generateMipmaps = false; this.restoreState(previousState); return target.texture; } private removeTextures() { this._ownedTextures.forEach(t => t.dispose()); this._ownedTextures.length = 0; } /** * Disposes all unmanaged resources in this composer. */ dispose() { this.removeTextures(); this.removeObjects(); if (this._renderTarget) { this._renderTarget.dispose(); } } } /** * Transfers the pixels of a RenderTarget in the RG format and float32 data type into a RGBA / 8bit. */ export function readRGRenderTargetIntoRGBAU8Buffer(options: { renderTarget: WebGLRenderTarget; renderer: WebGLRenderer; outputWidth: number; outputHeight: number; precision: number; offset: number; }): Uint8ClampedArray { const { renderTarget: originalRenderTarget, outputWidth, outputHeight, renderer } = options; let type = originalRenderTarget.texture.type; let format = originalRenderTarget.texture.format as PixelFormat; // WebGL mandates that only Unsigned 8-bit RGBA textures be readable, // all other combinations are optional and implementation defined. // https://registry.khronos.org/webgl/specs/latest/1.0/#5.14.12 const shouldConvert = type !== UnsignedByteType && format !== RGBAFormat; const buffer = new Uint8ClampedArray(outputWidth * outputHeight * 4); let target: WebGLRenderTarget = originalRenderTarget; if (shouldConvert) { format = RGBAFormat; type = UnsignedByteType; const rect = new Rect(0, 1, 0, 1); // Use the WebGLComposer to convert the render target into the proper format. // Note that the output render target is different than the input one. const composer = new WebGLComposer({ textureDataType: type, pixelFormat: format, webGLRenderer: renderer, reuseTexture: false, }); composer.draw(originalRenderTarget.texture, rect, { convertRGFloatToRGBAUnsignedByte: { precision: options.precision, offset: options.offset, }, }); target = new WebGLRenderTarget(outputWidth, outputHeight, { format, type, }); composer.render({ rect, target }); composer.dispose(); } // Transfer the elevation raster to CPU memory so that it can be sampled. renderer.readRenderTargetPixels(target, 0, 0, outputWidth, outputHeight, buffer); if (originalRenderTarget !== target) { target.dispose(); } return buffer; } export default WebGLComposer;