UNPKG

mapillary-js

Version:

A WebGL interactive street imagery library

515 lines (450 loc) 15.8 kB
import * as THREE from "three"; import { publishReplay, refCount, startWith, tap, } from "rxjs/operators"; import { Observable, of as observableOf, Subject, Subscription, } from "rxjs"; import { SubscriptionHolder } from "../util/SubscriptionHolder"; import { ImageTileEnt } from "../api/ents/ImageTileEnt"; import { TileRegionOfInterest } from "./interfaces/TileRegionOfInterest"; import { TileImageSize, TileCoords3D, TilePixelCoords2D, TileCoords2D, TileLevel, TILE_MIN_REQUEST_LEVEL, } from "./interfaces/TileTypes"; import { basicToTileCoords2D, cornersToTilesCoords2D, hasOverlap2D, clampedImageLevel, tileToPixelCoords2D, verifySize, baseImageLevel, } from "./TileMath"; import { TileLoader } from "./TileLoader"; import { TileStore } from "./TileStore"; /** * @class TextureProvider * * @classdesc Represents a provider of textures. */ export class TextureProvider { private readonly _loader: TileLoader; private readonly _store: TileStore; private readonly _subscriptions: Map<string, Subscription>; private readonly _urlSubscriptions: Map<number, Subscription>; private readonly _renderedLevel: Set<string>; private readonly _rendered: Map<string, TileCoords3D>; private readonly _created$: Observable<THREE.Texture>; private readonly _createdSubject$: Subject<THREE.Texture>; private readonly _hasSubject$: Subject<boolean>; private readonly _has$: Observable<boolean>; private readonly _updated$: Subject<boolean>; private readonly _holder: SubscriptionHolder; private readonly _size: TileImageSize; private readonly _imageId: string; private readonly _level: TileLevel; private _renderer: THREE.WebGLRenderer; private _render: { camera: THREE.OrthographicCamera; target: THREE.WebGLRenderTarget; }; private _background: HTMLImageElement; private _aborts: Function[]; private _disposed: boolean; /** * Create a new image texture provider instance. * * @param {string} imageId - The identifier of the image for which to request tiles. * @param {number} width - The full width of the original image. * @param {number} height - The full height of the original image. * @param {HTMLImageElement} background - Image to use as background. * @param {TileLoader} loader - Loader for retrieving tiles. * @param {TileStore} store - Store for saving tiles. * @param {THREE.WebGLRenderer} renderer - Renderer used for rendering tiles to texture. */ constructor( imageId: string, width: number, height: number, background: HTMLImageElement, loader: TileLoader, store: TileStore, renderer: THREE.WebGLRenderer) { const size = { h: height, w: width }; if (!verifySize(size)) { console.warn( `Original image size (${width}, ${height}) ` + `is invalid (${imageId}). Tiles will not be loaded.`); } this._imageId = imageId; this._size = size; this._level = { max: baseImageLevel(this._size), z: -1, }; this._holder = new SubscriptionHolder(); this._updated$ = new Subject<boolean>(); this._createdSubject$ = new Subject<THREE.Texture>(); this._created$ = this._createdSubject$ .pipe( publishReplay(1), refCount()); this._holder.push(this._created$.subscribe(() => { /*noop*/ })); this._hasSubject$ = new Subject<boolean>(); this._has$ = this._hasSubject$ .pipe( startWith(false), publishReplay(1), refCount()); this._holder.push(this._has$.subscribe(() => { /*noop*/ })); this._renderedLevel = new Set(); this._rendered = new Map(); this._subscriptions = new Map(); this._urlSubscriptions = new Map(); this._loader = loader; this._store = store; this._background = background; this._renderer = renderer; this._aborts = []; this._render = null; this._disposed = false; } /** * Get disposed. * * @returns {boolean} Value indicating whether provider has * been disposed. */ public get disposed(): boolean { return this._disposed; } /** * Get hasTexture$. * * @returns {Observable<boolean>} Observable emitting * values indicating when the existance of a texture * changes. */ public get hasTexture$(): Observable<boolean> { return this._has$; } /** * Get id. * * @returns {boolean} The identifier of the image for * which to render textures. */ public get id(): string { return this._imageId; } /** * Get textureUpdated$. * * @returns {Observable<boolean>} Observable emitting * values when an existing texture has been updated. */ public get textureUpdated$(): Observable<boolean> { return this._updated$; } /** * Get textureCreated$. * * @returns {Observable<boolean>} Observable emitting * values when a new texture has been created. */ public get textureCreated$(): Observable<THREE.Texture> { return this._created$; } /** * Abort all outstanding image tile requests. */ public abort(): void { this._subscriptions.forEach(sub => sub.unsubscribe()); this._subscriptions.clear(); for (const abort of this._aborts) { abort(); } this._aborts = []; } /** * Dispose the provider. * * @description Disposes all cached assets and * aborts all outstanding image tile requests. */ public dispose(): void { if (this._disposed) { console.warn(`Texture already disposed (${this._imageId})`); return; } this._urlSubscriptions.forEach(sub => sub.unsubscribe()); this._urlSubscriptions.clear(); this.abort(); if (this._render != null) { this._render.target.dispose(); this._render.target = null; this._render.camera = null; this._render = null; } this._store.dispose(); this._holder.unsubscribe(); this._renderedLevel.clear(); this._background = null; this._renderer = null; this._disposed = true; } /** * Set the region of interest. * * @description When the region of interest is set the * the tile level is determined and tiles for the region * are fetched from the store or the loader and renderedLevel * to the texture. * * @param {TileRegionOfInterest} roi - Spatial edges to cache. */ public setRegionOfInterest(roi: TileRegionOfInterest): void { if (!verifySize(this._size)) { return; } const virtualWidth = 1 / roi.pixelWidth; const virtualHeight = 1 / roi.pixelHeight; const level = clampedImageLevel( { h: virtualHeight, w: virtualWidth }, TILE_MIN_REQUEST_LEVEL, this._level.max); if (level !== this._level.z) { this.abort(); this._level.z = level; this._renderedLevel.clear(); this._rendered .forEach((tile, id) => { if (tile.z !== level) { return; } this._renderedLevel.add(id); }); } if (this._render == null) { this._initRender(); } const topLeft = basicToTileCoords2D( [roi.bbox.minX, roi.bbox.minY], this._size, this._level); const bottomRight = basicToTileCoords2D( [roi.bbox.maxX, roi.bbox.maxY], this._size, this._level); const tiles = cornersToTilesCoords2D( topLeft, bottomRight, this._size, this._level); this._fetchTiles(level, tiles); } /** * Retrieve an image tile. * * @description Retrieve an image tile and render it to the * texture. Add the tile to the store and emit to the updated * observable. * * @param {ImageTileEnt} tile - The tile ent. */ private _fetchTile(tile: ImageTileEnt): void { const getTile = this._loader.getImage$(tile.url); const tile$ = getTile[0]; const abort = getTile[1]; this._aborts.push(abort); const tileId = this._store.inventId(tile); const subscription = tile$.subscribe( (image: HTMLImageElement): void => { const pixels = tileToPixelCoords2D( tile, this._size, this._level); this._renderToTarget(pixels, image); this._subscriptions.delete(tileId); this._removeFromArray(abort, this._aborts); this._markRendered(tile); this._store.add(tileId, image); this._updated$.next(true); }, (error: Error): void => { this._subscriptions.delete(tileId); this._removeFromArray(abort, this._aborts); console.error(error); }); if (!subscription.closed) { this._subscriptions.set(tileId, subscription); } } /** * Fetch image tiles. * * @description Retrieve a image tiles and render them to the * texture. Retrieve from store if it exists, otherwise retrieve * from loader. * * @param {Array<TileCoords2D>} tiles - Array of tile coordinates to * retrieve. */ private _fetchTiles(level: number, tiles: TileCoords2D[]): void { const urls$ = this._store.hasURLLevel(level) ? observableOf(undefined) : this._loader .getURLs$(this._imageId, level) .pipe( tap(ents => { if (!this._store.hasURLLevel(level)) { this._store.addURLs(level, ents); } })); const subscription = urls$.subscribe( (): void => { if (level !== this._level.z) { return; } for (const tile of tiles) { const ent: ImageTileEnt = { x: tile.x, y: tile.y, z: level, url: null, }; const id = this._store.inventId(ent); if (this._renderedLevel.has(id) || this._subscriptions.has(id)) { continue; } if (this._store.has(id)) { const pixels = tileToPixelCoords2D( tile, this._size, this._level); this._renderToTarget( pixels, this._store.get(id)); this._markRendered(ent); this._updated$.next(true); continue; } ent.url = this._store.getURL(id); this._fetchTile(ent); } this._urlSubscriptions.delete(level); }, (error: Error): void => { this._urlSubscriptions.delete(level); console.error(error); }); if (!subscription.closed) { this._urlSubscriptions.set(level, subscription); } } private _initRender(): void { const dx = this._size.w / 2; const dy = this._size.h / 2; const near = -1; const far = 1; const camera = new THREE.OrthographicCamera(-dx, dx, dy, -dy, near, far); camera.position.z = 1; const gl = this._renderer.getContext(); const maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE); const backgroundSize = Math.max(this._size.w, this._size.h); const scale = maxTextureSize > backgroundSize ? 1 : maxTextureSize / backgroundSize; const targetWidth = Math.floor(scale * this._size.w); const targetHeight = Math.floor(scale * this._size.h); const target = new THREE.WebGLRenderTarget( targetWidth, targetHeight, { depthBuffer: false, format: THREE.RGBFormat, magFilter: THREE.LinearFilter, minFilter: THREE.LinearFilter, stencilBuffer: false, }); this._render = { camera, target }; const pixels = tileToPixelCoords2D( { x: 0, y: 0 }, this._size, { max: this._level.max, z: 0 }); this._renderToTarget(pixels, this._background); this._createdSubject$.next(target.texture); this._hasSubject$.next(true); } /** * Mark a tile as rendered. * * @description Clears tiles marked as rendered in other * levels of the tile pyramid if they overlap the * newly rendered tile. * * @param {Arrary<number>} tile - The tile ent. */ private _markRendered(tile: ImageTileEnt): void { const others = Array.from(this._rendered.entries()) .filter( ([_, t]: [string, TileCoords3D]): boolean => { return t.z !== tile.z; }); for (const [otherId, other] of others) { if (hasOverlap2D(tile, other)) { this._rendered.delete(otherId); } } const id = this._store.inventId(tile); this._rendered.set(id, tile); this._renderedLevel.add(id); } /** * Remove an item from an array if it exists in array. * * @param {T} item - Item to remove. * @param {Array<T>} array - Array from which item should be removed. */ private _removeFromArray<T>(item: T, array: T[]): void { const index = array.indexOf(item); if (index !== -1) { array.splice(index, 1); } } /** * Render an image tile to the target texture. * * @param {ImageTileEnt} tile - Tile ent. * @param {HTMLImageElement} image - The image tile to render. */ private _renderToTarget( pixel: TilePixelCoords2D, image: HTMLImageElement): void { const texture = new THREE.Texture(image); texture.minFilter = THREE.LinearFilter; texture.needsUpdate = true; const geometry = new THREE.PlaneGeometry(pixel.w, pixel.h); const material = new THREE.MeshBasicMaterial({ map: texture, side: THREE.FrontSide, }); const mesh = new THREE.Mesh(geometry, material); mesh.position.x = -this._size.w / 2 + pixel.x + pixel.w / 2; mesh.position.y = this._size.h / 2 - pixel.y - pixel.h / 2; const scene = new THREE.Scene(); scene.add(mesh); const target = this._renderer.getRenderTarget(); this._renderer.resetState(); this._renderer.setRenderTarget(this._render.target) this._renderer.render(scene, this._render.camera); this._renderer.setRenderTarget(target); scene.remove(mesh); geometry.dispose(); material.dispose(); texture.dispose(); } }