UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

352 lines (336 loc) 12.7 kB
import { ClampToEdgeWrapping, Color, LinearFilter, MathUtils, Mesh, OrthographicCamera, PlaneGeometry, RGBAFormat, Scene, Texture, UnsignedByteType, Vector4, WebGLRenderTarget } 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 = null; const IMAGE_Z = -10; const textureOwners = new Map(); const NEAR = 1; const FAR = 100; const DEFAULT_CLEAR = new Color(0, 0, 0); function processTextureDisposal(event) { 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); } } /** * Composes images together using a three.js scene and an orthographic camera. */ class WebGLComposer { /** * Creates an instance of WebGLComposer. * * @param options - The options. */ constructor(options) { 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. */ setCameraRect(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(); } createRenderTarget(type, format, width, height) { 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, extent, options = {}) { // @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, mesh, options = {}) { let texture; if (!isTexture(image)) { texture = new Texture(image); texture.needsUpdate = true; this._ownedTextures.push(texture); MemoryTracker.track(texture, 'WebGLComposer - owned texture'); } else { texture = image; } 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) { ComposerTileMaterial.release(mesh.material); this._scene.remove(mesh); } /** * Resets the composer to a blank state. */ clear() { this.removeTextures(); this.removeObjects(); } removeObjects() { this._scene.traverse(obj => { if (isMesh(obj) && isComposerTileMaterial(obj.material)) { ComposerTileMaterial.release(obj.material); } }); this._scene.clear(); } 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()) }; } restoreState(state) { 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 = {}) { 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; } 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) { const { renderTarget: originalRenderTarget, outputWidth, outputHeight, renderer } = options; let type = originalRenderTarget.texture.type; let format = originalRenderTarget.texture.format; // 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 = 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;