UNPKG

photo-sphere-viewer

Version:

A JavaScript library to display Photo Sphere panoramas

703 lines (598 loc) 21.9 kB
import { Frustum, ImageLoader, MathUtils, Matrix4, Mesh, MeshBasicMaterial, SphereGeometry, Vector3 } from 'three'; import { CONSTANTS, EquirectangularAdapter, PSVError, utils } from '../..'; import { Queue } from '../shared/Queue'; import { Task } from '../shared/Task'; import { buildErrorMaterial, createBaseTexture } from '../shared/tiles-utils'; /** * @callback TileUrl * @summary Function called to build a tile url * @memberOf PSV.adapters.EquirectangularTilesAdapter * @param {int} col * @param {int} row * @returns {string} */ /** * @typedef {Object} PSV.adapters.EquirectangularTilesAdapter.Panorama * @summary Configuration of a tiled panorama * @property {string} [baseUrl] - low resolution panorama loaded before tiles * @property {PSV.PanoData | PSV.PanoDataProvider} [basePanoData] - panoData configuration associated to low resolution panorama loaded before tiles * @property {int} width - complete panorama width (height is always width/2) * @property {int} cols - number of vertical tiles * @property {int} rows - number of horizontal tiles * @property {PSV.adapters.EquirectangularTilesAdapter.TileUrl} tileUrl - function to build a tile url */ /** * @typedef {Object} PSV.adapters.EquirectangularTilesAdapter.Options * @property {number} [resolution=64] - number of faces of the sphere geometry, higher values may decrease performances * @property {boolean} [showErrorTile=true] - shows a warning sign on tiles that cannot be loaded * @property {boolean} [baseBlur=true] - applies a blur to the low resolution panorama */ /** * @typedef {Object} PSV.adapters.EquirectangularTilesAdapter.Tile * @private * @property {int} col * @property {int} row * @property {float} angle */ /* the faces of the top and bottom rows are made of a single triangle (3 vertices) * all other faces are made of two triangles (6 vertices) * bellow is the indexing of each face vertices * * first row faces: * ⋀ * /0\ * / \ * / \ * /1 2\ * ¯¯¯¯¯¯¯¯¯ * * other rows faces: * _________ * |\1 0| * |3\ | * | \ | * | \ | * | \ | * | \2| * |4 5\| * ¯¯¯¯¯¯¯¯¯ * * last row faces: * _________ * \1 0/ * \ / * \ / * \2/ * ⋁ */ const ATTR_UV = 'uv'; const ATTR_ORIGINAL_UV = 'originaluv'; const ATTR_POSITION = 'position'; function tileId(tile) { return `${tile.col}x${tile.row}`; } const frustum = new Frustum(); const projScreenMatrix = new Matrix4(); const vertexPosition = new Vector3(); /** * @summary Adapter for tiled panoramas * @memberof PSV.adapters * @extends PSV.adapters.AbstractAdapter */ export class EquirectangularTilesAdapter extends EquirectangularAdapter { static id = 'equirectangular-tiles'; static supportsDownload = false; static supportsOverlay = false; /** * @param {PSV.Viewer} psv * @param {PSV.adapters.EquirectangularTilesAdapter.Options} options */ constructor(psv, options) { super(psv); this.psv.config.useXmpData = false; /** * @member {PSV.adapters.EquirectangularTilesAdapter.Options} * @private */ this.config = { resolution : 64, showErrorTile: true, baseBlur : true, ...options, }; if (!MathUtils.isPowerOfTwo(this.config.resolution)) { throw new PSVError('EquirectangularAdapter resolution must be power of two'); } this.SPHERE_SEGMENTS = this.config.resolution; this.SPHERE_HORIZONTAL_SEGMENTS = this.SPHERE_SEGMENTS / 2; this.NB_VERTICES_BY_FACE = 6; this.NB_VERTICES_BY_SMALL_FACE = 3; this.NB_VERTICES = 2 * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE + (this.SPHERE_HORIZONTAL_SEGMENTS - 2) * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE; this.NB_GROUPS = this.SPHERE_SEGMENTS * this.SPHERE_HORIZONTAL_SEGMENTS; /** * @member {PSV.adapters.Queue} * @private */ this.queue = new Queue(); /** * @type {Object} * @property {int} colSize - size in pixels of a column * @property {int} rowSize - size in pixels of a row * @property {int} facesByCol - number of mesh faces by column * @property {int} facesByRow - number of mesh faces by row * @property {Record<string, boolean>} tiles - loaded tiles * @property {external:THREE.SphereGeometry} geom * @property {external:THREE.MeshBasicMaterial[]} materials * @property {external:THREE.MeshBasicMaterial} errorMaterial * @private */ this.prop = { colSize : 0, rowSize : 0, facesByCol : 0, facesByRow : 0, tiles : {}, geom : null, materials : [], errorMaterial: null, }; /** * @member {external:THREE.ImageLoader} * @private */ this.loader = null; if (this.psv.config.requestHeaders) { utils.logWarn('EquirectangularTilesAdapter fallbacks to file loader because "requestHeaders" where provided. ' + 'Consider removing "requestHeaders" if you experience performances issues.'); } else { this.loader = new ImageLoader(); if (this.psv.config.withCredentials) { this.loader.setWithCredentials(true); } } this.psv.on(CONSTANTS.EVENTS.POSITION_UPDATED, this); this.psv.on(CONSTANTS.EVENTS.ZOOM_UPDATED, this); } /** * @override */ destroy() { this.psv.off(CONSTANTS.EVENTS.POSITION_UPDATED, this); this.psv.off(CONSTANTS.EVENTS.ZOOM_UPDATED, this); this.__cleanup(); this.prop.errorMaterial?.map?.dispose(); this.prop.errorMaterial?.dispose(); delete this.queue; delete this.loader; delete this.prop.geom; delete this.prop.errorMaterial; super.destroy(); } /** * @private */ handleEvent(e) { /* eslint-disable */ switch (e.type) { case CONSTANTS.EVENTS.POSITION_UPDATED: case CONSTANTS.EVENTS.ZOOM_UPDATED: this.__refresh(); break; } /* eslint-enable */ } /** * @summary Clears loading queue, dispose all materials * @private */ __cleanup() { this.queue.clear(); this.prop.tiles = {}; this.prop.materials.forEach((mat) => { mat?.map?.dispose(); mat?.dispose(); }); this.prop.materials.length = 0; } /** * @override */ supportsTransition(panorama) { return !!panorama.baseUrl; } /** * @override */ supportsPreload(panorama) { return !!panorama.baseUrl; } /** * @override * @param {PSV.adapters.EquirectangularTilesAdapter.Panorama} panorama * @returns {Promise.<PSV.TextureData>} */ loadTexture(panorama) { if (typeof panorama !== 'object' || !panorama.width || !panorama.cols || !panorama.rows || !panorama.tileUrl) { return Promise.reject(new PSVError('Invalid panorama configuration, are you using the right adapter?')); } if (panorama.cols > this.SPHERE_SEGMENTS) { return Promise.reject(new PSVError(`Panorama cols must not be greater than ${this.SPHERE_SEGMENTS}.`)); } if (panorama.rows > this.SPHERE_HORIZONTAL_SEGMENTS) { return Promise.reject(new PSVError(`Panorama rows must not be greater than ${this.SPHERE_HORIZONTAL_SEGMENTS}.`)); } if (!MathUtils.isPowerOfTwo(panorama.cols) || !MathUtils.isPowerOfTwo(panorama.rows)) { return Promise.reject(new PSVError('Panorama cols and rows must be powers of 2.')); } const panoData = { fullWidth : panorama.width, fullHeight : panorama.width / 2, croppedWidth : panorama.width, croppedHeight: panorama.width / 2, croppedX : 0, croppedY : 0, poseHeading : 0, posePitch : 0, poseRoll : 0, }; if (panorama.baseUrl) { return super.loadTexture(panorama.baseUrl, panorama.basePanoData) .then(textureData => ({ panorama: panorama, texture : textureData.texture, panoData: panoData, })); } else { return Promise.resolve({ panorama, panoData }); } } /** * @override */ createMesh(scale = 1) { const geometry = new SphereGeometry( CONSTANTS.SPHERE_RADIUS * scale, this.SPHERE_SEGMENTS, this.SPHERE_HORIZONTAL_SEGMENTS, -Math.PI / 2 ) .scale(-1, 1, 1) .toNonIndexed(); geometry.clearGroups(); let i = 0; let k = 0; // first row for (; i < this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE; i += this.NB_VERTICES_BY_SMALL_FACE) { geometry.addGroup(i, this.NB_VERTICES_BY_SMALL_FACE, k++); } // second to before last rows for (; i < this.NB_VERTICES - this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE; i += this.NB_VERTICES_BY_FACE) { geometry.addGroup(i, this.NB_VERTICES_BY_FACE, k++); } // last row for (; i < this.NB_VERTICES; i += this.NB_VERTICES_BY_SMALL_FACE) { geometry.addGroup(i, this.NB_VERTICES_BY_SMALL_FACE, k++); } geometry.setAttribute(ATTR_ORIGINAL_UV, geometry.getAttribute(ATTR_UV).clone()); return new Mesh(geometry, []); } /** * @summary Applies the base texture and starts the loading of tiles * @override */ setTexture(mesh, textureData, transition) { const { panorama, texture } = textureData; if (transition) { this.__setTexture(mesh, texture); return; } this.__cleanup(); this.__setTexture(mesh, texture); this.prop.materials = mesh.material; this.prop.geom = mesh.geometry; this.prop.geom.setAttribute(ATTR_UV, this.prop.geom.getAttribute(ATTR_ORIGINAL_UV).clone()); this.prop.colSize = panorama.width / panorama.cols; this.prop.rowSize = panorama.width / 2 / panorama.rows; this.prop.facesByCol = this.SPHERE_SEGMENTS / panorama.cols; this.prop.facesByRow = this.SPHERE_HORIZONTAL_SEGMENTS / panorama.rows; // this.psv.renderer.scene.add(createWireFrame(this.prop.geom)); setTimeout(() => this.__refresh(true)); } /** * @private */ __setTexture(mesh, texture) { let material; if (texture) { material = new MeshBasicMaterial({ map: texture }); } else { material = new MeshBasicMaterial({ opacity: 0, transparent: true }); } for (let i = 0; i < this.NB_GROUPS; i++) { mesh.material.push(material); } } /** * @override */ setTextureOpacity(mesh, opacity) { mesh.material[0].opacity = opacity; mesh.material[0].transparent = opacity < 1; } /** * @summary Compute visible tiles and load them * @param {boolean} [init=false] Indicates initial call * @private */ __refresh(init = false) { // eslint-disable-line no-unused-vars if (!this.prop.geom) { return; } const camera = this.psv.renderer.camera; camera.updateMatrixWorld(); projScreenMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); frustum.setFromProjectionMatrix(projScreenMatrix); const panorama = this.psv.config.panorama; const verticesPosition = this.prop.geom.getAttribute(ATTR_POSITION); const tilesToLoad = []; for (let col = 0; col < panorama.cols; col++) { for (let row = 0; row < panorama.rows; row++) { // for each tile, find the vertices corresponding to the four corners (three for first and last rows) // if at least one vertex is visible, the tile must be loaded // for larger tiles we also test the four edges centers and the tile center const verticesIndex = []; if (row === 0) { // bottom-left const v0 = this.prop.facesByRow === 1 ? col * this.prop.facesByCol * this.NB_VERTICES_BY_SMALL_FACE + 1 : this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE + (this.prop.facesByRow - 2) * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE + col * this.prop.facesByCol * this.NB_VERTICES_BY_FACE + 4; // bottom-right const v1 = this.prop.facesByRow === 1 ? v0 + (this.prop.facesByCol - 1) * this.NB_VERTICES_BY_SMALL_FACE + 1 : v0 + (this.prop.facesByCol - 1) * this.NB_VERTICES_BY_FACE + 1; // top (all vertices are equal) const v2 = 0; verticesIndex.push(v0, v1, v2); if (this.prop.facesByCol >= this.SPHERE_SEGMENTS / 8) { // bottom-center const v4 = v0 + this.prop.facesByCol / 2 * this.NB_VERTICES_BY_FACE; verticesIndex.push(v4); } if (this.prop.facesByRow >= this.SPHERE_HORIZONTAL_SEGMENTS / 4) { // left-center const v6 = v0 - this.prop.facesByRow / 2 * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE; // right-center const v7 = v1 - this.prop.facesByRow / 2 * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE; verticesIndex.push(v6, v7); } } else if (row === panorama.rows - 1) { // top-left const v0 = this.prop.facesByRow === 1 ? -this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE + row * this.prop.facesByRow * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE + col * this.prop.facesByCol * this.NB_VERTICES_BY_SMALL_FACE + 1 : -this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE + row * this.prop.facesByRow * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE + col * this.prop.facesByCol * this.NB_VERTICES_BY_FACE + 1; // top-right const v1 = this.prop.facesByRow === 1 ? v0 + (this.prop.facesByCol - 1) * this.NB_VERTICES_BY_SMALL_FACE - 1 : v0 + (this.prop.facesByCol - 1) * this.NB_VERTICES_BY_FACE - 1; // bottom (all vertices are equal) const v2 = this.NB_VERTICES - 1; verticesIndex.push(v0, v1, v2); if (this.prop.facesByCol >= this.SPHERE_SEGMENTS / 8) { // top-center const v4 = v0 + this.prop.facesByCol / 2 * this.NB_VERTICES_BY_FACE; verticesIndex.push(v4); } if (this.prop.facesByRow >= this.SPHERE_HORIZONTAL_SEGMENTS / 4) { // left-center const v6 = v0 + this.prop.facesByRow / 2 * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE; // right-center const v7 = v1 + this.prop.facesByRow / 2 * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE; verticesIndex.push(v6, v7); } } else { // top-left const v0 = -this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE + row * this.prop.facesByRow * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE + col * this.prop.facesByCol * this.NB_VERTICES_BY_FACE + 1; // bottom-left const v1 = v0 + (this.prop.facesByRow - 1) * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE + 3; // bottom-right const v2 = v1 + (this.prop.facesByCol - 1) * this.NB_VERTICES_BY_FACE + 1; // top-right const v3 = v0 + (this.prop.facesByCol - 1) * this.NB_VERTICES_BY_FACE - 1; verticesIndex.push(v0, v1, v2, v3); if (this.prop.facesByCol >= this.SPHERE_SEGMENTS / 8) { // top-center const v4 = v0 + this.prop.facesByCol / 2 * this.NB_VERTICES_BY_FACE; // bottom-center const v5 = v1 + this.prop.facesByCol / 2 * this.NB_VERTICES_BY_FACE; verticesIndex.push(v4, v5); } if (this.prop.facesByRow >= this.SPHERE_HORIZONTAL_SEGMENTS / 4) { // left-center const v6 = v0 + this.prop.facesByRow / 2 * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE; // right-center const v7 = v3 + this.prop.facesByRow / 2 * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE; verticesIndex.push(v6, v7); if (this.prop.facesByCol >= this.SPHERE_SEGMENTS / 8) { // center-center const v8 = v6 + this.prop.facesByCol / 2 * this.NB_VERTICES_BY_FACE; verticesIndex.push(v8); } } } // if (init && col === 0 && row === 0) { // verticesIndex.forEach((vertexIdx) => { // this.psv.renderer.scene.add(createDot( // verticesPosition.getX(vertexIdx), // verticesPosition.getY(vertexIdx), // verticesPosition.getZ(vertexIdx) // )); // }); // } const vertexVisible = verticesIndex.some((vertexIdx) => { vertexPosition.set( verticesPosition.getX(vertexIdx), verticesPosition.getY(vertexIdx), verticesPosition.getZ(vertexIdx) ); vertexPosition.applyEuler(this.psv.renderer.meshContainer.rotation); return frustum.containsPoint(vertexPosition); }); if (vertexVisible) { let angle = vertexPosition.angleTo(this.psv.prop.direction); if (row === 0 || row === panorama.rows - 1) { angle *= 2; // lower priority to top and bottom tiles } tilesToLoad.push({ col, row, angle }); } } } this.__loadTiles(tilesToLoad); } /** * @summary Loads tiles and change existing tiles priority * @param {PSV.adapters.EquirectangularTilesAdapter.Tile[]} tiles * @private */ __loadTiles(tiles) { this.queue.disableAllTasks(); tiles.forEach((tile) => { const id = tileId(tile); if (this.prop.tiles[id]) { this.queue.setPriority(id, tile.angle); } else { this.prop.tiles[id] = true; this.queue.enqueue(new Task(id, tile.angle, task => this.__loadTile(tile, task))); } }); this.queue.start(); } /** * @summary Loads and draw a tile * @param {PSV.adapters.EquirectangularTilesAdapter.Tile} tile * @param {PSV.adapters.Task} task * @return {Promise} * @private */ __loadTile(tile, task) { const panorama = this.psv.config.panorama; const url = panorama.tileUrl(tile.col, tile.row); return this.__loadImage(url) .then((image) => { if (!task.isCancelled()) { const material = new MeshBasicMaterial({ map: utils.createTexture(image) }); this.__swapMaterial(tile.col, tile.row, material); this.psv.needsUpdate(); } }) .catch(() => { if (!task.isCancelled() && this.config.showErrorTile) { if (!this.prop.errorMaterial) { this.prop.errorMaterial = buildErrorMaterial(this.prop.colSize, this.prop.rowSize); } this.__swapMaterial(tile.col, tile.row, this.prop.errorMaterial); this.psv.needsUpdate(); } }); } /** * @private */ __loadImage(url) { if (this.loader) { return new Promise((resolve, reject) => { this.loader.load(url, resolve, undefined, reject); }); } else { return this.psv.textureLoader.loadImage(url); } } /** * @summary Applies a new texture to the faces * @param {int} col * @param {int} row * @param {external:THREE.MeshBasicMaterial} material * @private */ __swapMaterial(col, row, material) { const uvs = this.prop.geom.getAttribute(ATTR_UV); for (let c = 0; c < this.prop.facesByCol; c++) { for (let r = 0; r < this.prop.facesByRow; r++) { // position of the face (two triangles of the same square) const faceCol = col * this.prop.facesByCol + c; const faceRow = row * this.prop.facesByRow + r; const isFirstRow = faceRow === 0; const isLastRow = faceRow === (this.SPHERE_HORIZONTAL_SEGMENTS - 1); // first vertex for this face (3 or 6 vertices in total) let firstVertex; if (isFirstRow) { firstVertex = faceCol * this.NB_VERTICES_BY_SMALL_FACE; } else if (isLastRow) { firstVertex = this.NB_VERTICES - this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE + faceCol * this.NB_VERTICES_BY_SMALL_FACE; } else { firstVertex = this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_SMALL_FACE + (faceRow - 1) * this.SPHERE_SEGMENTS * this.NB_VERTICES_BY_FACE + faceCol * this.NB_VERTICES_BY_FACE; } // swap material const matIndex = this.prop.geom.groups.find(g => g.start === firstVertex).materialIndex; this.prop.materials[matIndex] = material; // define new uvs const top = 1 - r / this.prop.facesByRow; const bottom = 1 - (r + 1) / this.prop.facesByRow; const left = c / this.prop.facesByCol; const right = (c + 1) / this.prop.facesByCol; if (isFirstRow) { uvs.setXY(firstVertex, (left + right) / 2, top); uvs.setXY(firstVertex + 1, left, bottom); uvs.setXY(firstVertex + 2, right, bottom); } else if (isLastRow) { uvs.setXY(firstVertex, right, top); uvs.setXY(firstVertex + 1, left, top); uvs.setXY(firstVertex + 2, (left + right) / 2, bottom); } else { uvs.setXY(firstVertex, right, top); uvs.setXY(firstVertex + 1, left, top); uvs.setXY(firstVertex + 2, right, bottom); uvs.setXY(firstVertex + 3, left, top); uvs.setXY(firstVertex + 4, left, bottom); uvs.setXY(firstVertex + 5, right, bottom); } } } uvs.needsUpdate = true; } /** * @summary Create the texture for the base image * @param {HTMLImageElement} img * @return {external:THREE.Texture} * @private */ __createBaseTexture(img) { if (img.width !== img.height * 2) { utils.logWarn('Invalid base image, the width should be twice the height'); } return createBaseTexture(img, this.config.baseBlur, w => w / 2); } }