// loaders.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors // This file is derived from the Cesium code base under Apache 2 license // See LICENSE.md and https://github.com/AnalyticalGraphicsInc/cesium/blob/master/LICENSE.md import { Vector3, Matrix4 } from '@math.gl/core'; import { CullingVolume } from '@math.gl/culling'; import { load } from '@loaders.gl/core'; import { TILE_REFINEMENT, TILE_CONTENT_STATE, TILESET_TYPE } from "../constants.js"; import { createBoundingVolume, getCartographicBounds } from "./helpers/bounding-volume.js"; import { getTiles3DScreenSpaceError } from "./helpers/tiles-3d-lod.js"; import { getProjectedRadius } from "./helpers/i3s-lod.js"; import { get3dTilesOptions } from "./helpers/3d-tiles-options.js"; import { TilesetTraverser } from "./tileset-traverser.js"; const scratchVector = new Vector3(); function defined(x) { return x !== undefined && x !== null; } /** * A Tile3DHeader represents a tile as Tileset3D. When a tile is first created, its content is not loaded; * the content is loaded on-demand when needed based on the view. * Do not construct this directly, instead access tiles through {@link Tileset3D#tileVisible}. */ export class Tile3D { tileset; header; id; url; parent; /* Specifies the type of refine that is used when traversing this tile for rendering. */ refine; type; contentUrl; /** Different refinement algorithms used by I3S and 3D tiles */ lodMetricType = 'geometricError'; /** The error, in meters, introduced if this tile is rendered and its children are not. */ lodMetricValue = 0; /** @todo math.gl is not exporting BoundingVolume base type? */ boundingVolume = null; /** * The tile's content. This represents the actual tile's payload, * not the content's metadata in the tileset JSON file. */ content = null; contentState = TILE_CONTENT_STATE.UNLOADED; gpuMemoryUsageInBytes = 0; /** The tile's children - an array of Tile3D objects. */ children = []; depth = 0; viewportIds = []; transform = new Matrix4(); extensions = null; /** TODO Cesium 3d tiles specific */ implicitTiling = null; /** Container to store application specific data */ userData = {}; computedTransform; hasEmptyContent = false; hasTilesetContent = false; traverser = new TilesetTraverser({}); /** Used by TilesetCache */ _cacheNode = null; _frameNumber = null; // TODO Cesium 3d tiles specific _expireDate = null; _expiredContent = null; _boundingBox = undefined; /** updated every frame for tree traversal and rendering optimizations: */ _distanceToCamera = 0; _screenSpaceError = 0; _visibilityPlaneMask; _visible = undefined; _contentBoundingVolume; _viewerRequestVolume; _initialTransform = new Matrix4(); // Used by traverser, cannot be marked private _priority = 0; _selectedFrame = 0; _requestedFrame = 0; _selectionDepth = 0; _touchedFrame = 0; _centerZDepth = 0; _shouldRefine = false; _stackLength = 0; _visitedFrame = 0; _inRequestVolume = false; _lodJudge = null; // TODO i3s specific, needs to remove /** * @constructs * Create a Tile3D instance * @param tileset - Tileset3D instance * @param header - tile header - JSON loaded from a dataset * @param parentHeader - parent Tile3D instance * @param extendedId - optional ID to separate copies of a tile for different viewports. * const extendedId = `${tile.id}-${frameState.viewport.id}`; */ // eslint-disable-next-line max-statements constructor(tileset, header, parentHeader, extendedId = '') { // PUBLIC MEMBERS // original tile data this.header = header; // The tileset containing this tile. this.tileset = tileset; this.id = extendedId || header.id; this.url = header.url; // This tile's parent or `undefined` if this tile is the root. // @ts-ignore this.parent = parentHeader; this.refine = this._getRefine(header.refine); this.type = header.type; this.contentUrl = header.contentUrl; this._initializeLodMetric(header); this._initializeTransforms(header); this._initializeBoundingVolumes(header); this._initializeContent(header); this._initializeRenderingState(header); Object.seal(this); } destroy() { this.header = null; } isDestroyed() { return this.header === null; } get selected() { return this._selectedFrame === this.tileset._frameNumber; } get isVisible() { return this._visible; } get isVisibleAndInRequestVolume() { return this._visible && this._inRequestVolume; } /** Returns true if tile is not an empty tile and not an external tileset */ get hasRenderContent() { return !this.hasEmptyContent && !this.hasTilesetContent; } /** Returns true if tile has children */ get hasChildren() { return this.children.length > 0 || (this.header.children && this.header.children.length > 0); } /** * Determines if the tile's content is ready. This is automatically `true` for * tiles with empty content. */ get contentReady() { return this.contentState === TILE_CONTENT_STATE.READY || this.hasEmptyContent; } /** * Determines if the tile has available content to render. `true` if the tile's * content is ready or if it has expired content this renders while new content loads; otherwise, */ get contentAvailable() { return Boolean((this.contentReady && this.hasRenderContent) || (this._expiredContent && !this.contentFailed)); } /** Returns true if tile has renderable content but it's unloaded */ get hasUnloadedContent() { return this.hasRenderContent && this.contentUnloaded; } /** * Determines if the tile's content has not be requested. `true` if tile's * content has not be requested; otherwise, `false`. */ get contentUnloaded() { return this.contentState === TILE_CONTENT_STATE.UNLOADED; } /** * Determines if the tile's content is expired. `true` if tile's * content is expired; otherwise, `false`. */ get contentExpired() { return this.contentState === TILE_CONTENT_STATE.EXPIRED; } // Determines if the tile's content failed to load. `true` if the tile's // content failed to load; otherwise, `false`. get contentFailed() { return this.contentState === TILE_CONTENT_STATE.FAILED; } /** * Distance from the tile's bounding volume center to the camera */ get distanceToCamera() { return this._distanceToCamera; } /** * Screen space error for LOD selection */ get screenSpaceError() { return this._screenSpaceError; } /** * Get bounding box in cartographic coordinates * @returns [min, max] each in [longitude, latitude, altitude] */ get boundingBox() { if (!this._boundingBox) { this._boundingBox = getCartographicBounds(this.header.boundingVolume, this.boundingVolume); } return this._boundingBox; } /** Get the tile's screen space error. */ getScreenSpaceError(frameState, useParentLodMetric) { switch (this.tileset.type) { case TILESET_TYPE.I3S: return getProjectedRadius(this, frameState); case TILESET_TYPE.TILES3D: return getTiles3DScreenSpaceError(this, frameState, useParentLodMetric); default: // eslint-disable-next-line throw new Error('Unsupported tileset type'); } } /** * Make tile unselected than means it won't be shown * but it can be still loaded in memory */ unselect() { this._selectedFrame = 0; } /** * Memory usage of tile on GPU */ _getGpuMemoryUsageInBytes() { return this.content.gpuMemoryUsageInBytes || this.content.byteLength || 0; } /* * If skipLevelOfDetail is off try to load child tiles as soon as possible so that their parent can refine sooner. * Tiles are prioritized by screen space error. */ // eslint-disable-next-line complexity _getPriority() { const traverser = this.tileset._traverser; const { skipLevelOfDetail } = traverser.options; /* * Tiles that are outside of the camera's frustum could be skipped if we are in 'ADD' mode * or if we are using 'Skip Traversal' in 'REPLACE' mode. * Otherewise, all 'touched' child tiles have to be loaded and displayed, * this may include tiles that are outide of the camera frustum (so that we can hide the parent tile). */ const maySkipTile = this.refine === TILE_REFINEMENT.ADD || skipLevelOfDetail; // Check if any reason to abort if (maySkipTile && !this.isVisible && this._visible !== undefined) { return -1; } // Condition used in `cancelOutOfViewRequests` function in CesiumJS/Cesium3DTileset.js if (this.tileset._frameNumber - this._touchedFrame >= 1) { return -1; } if (this.contentState === TILE_CONTENT_STATE.UNLOADED) { return -1; } // Based on the priority function `getPriorityReverseScreenSpaceError` in CesiumJS. Scheduling priority is based on the parent's screen space error when possible. const parent = this.parent; const useParentScreenSpaceError = parent && (!maySkipTile || this._screenSpaceError === 0.0 || parent.hasTilesetContent); const screenSpaceError = useParentScreenSpaceError ? parent._screenSpaceError : this._screenSpaceError; const rootScreenSpaceError = traverser.root ? traverser.root._screenSpaceError : 0.0; // Map higher SSE to lower values (e.g. root tile is highest priority) return Math.max(rootScreenSpaceError - screenSpaceError, 0); } /** * Requests the tile's content. * The request may not be made if the Request Scheduler can't prioritize it. */ // eslint-disable-next-line max-statements, complexity async loadContent() { if (this.hasEmptyContent) { return false; } if (this.content) { return true; } const expired = this.contentExpired; if (expired) { this._expireDate = null; } this.contentState = TILE_CONTENT_STATE.LOADING; const requestToken = await this.tileset._requestScheduler.scheduleRequest(this.id, this._getPriority.bind(this)); if (!requestToken) { // cancelled this.contentState = TILE_CONTENT_STATE.UNLOADED; return false; } try { const contentUrl = this.tileset.getTileUrl(this.contentUrl); // The content can be a binary tile ot a JSON tileset const loader = this.tileset.loader; const options = { ...this.tileset.loadOptions, [loader.id]: { // @ts-expect-error ...this.tileset.loadOptions[loader.id], isTileset: this.type === 'json', ...this._getLoaderSpecificOptions(loader.id) } }; this.content = await load(contentUrl, loader, options); if (this.tileset.options.contentLoader) { await this.tileset.options.contentLoader(this); } if (this._isTileset()) { // Add tile headers for the nested tilset's subtree // Async update of the tree should be fine since there would never be edits to the same node // TODO - we need to capture the child tileset's URL this.tileset._initializeTileHeaders(this.content, this); } this.contentState = TILE_CONTENT_STATE.READY; this._onContentLoaded(); return true; } catch (error) { // Tile is unloaded before the content finishes loading this.contentState = TILE_CONTENT_STATE.FAILED; throw error; } finally { requestToken.done(); } } // Unloads the tile's content. unloadContent() { if (this.content && this.content.destroy) { this.content.destroy(); } this.content = null; if (this.header.content && this.header.content.destroy) { this.header.content.destroy(); } this.header.content = null; this.contentState = TILE_CONTENT_STATE.UNLOADED; return true; } /** * Update the tile's visibility * @param {Object} frameState - frame state for tile culling * @param {string[]} viewportIds - a list of viewport ids that show this tile * @return {void} */ updateVisibility(frameState, viewportIds) { if (this._frameNumber === frameState.frameNumber) { // Return early if visibility has already been checked during the traversal. // The visibility may have already been checked if the cullWithChildrenBounds optimization is used. return; } const parent = this.parent; const parentVisibilityPlaneMask = parent ? parent._visibilityPlaneMask : CullingVolume.MASK_INDETERMINATE; if (this.tileset._traverser.options.updateTransforms) { const parentTransform = parent ? parent.computedTransform : this.tileset.modelMatrix; this._updateTransform(parentTransform); } this._distanceToCamera = this.distanceToTile(frameState); this._screenSpaceError = this.getScreenSpaceError(frameState, false); this._visibilityPlaneMask = this.visibility(frameState, parentVisibilityPlaneMask); // Use parent's plane mask to speed up visibility test this._visible = this._visibilityPlaneMask !== CullingVolume.MASK_OUTSIDE; this._inRequestVolume = this.insideViewerRequestVolume(frameState); this._frameNumber = frameState.frameNumber; this.viewportIds = viewportIds; } // Determines whether the tile's bounding volume intersects the culling volume. // @param {FrameState} frameState The frame state. // @param {Number} parentVisibilityPlaneMask The parent's plane mask to speed up the visibility check. // @returns {Number} A plane mask as described above in {@link CullingVolume#computeVisibilityWithPlaneMask}. visibility(frameState, parentVisibilityPlaneMask) { const { cullingVolume } = frameState; const { boundingVolume } = this; // TODO Cesium specific - restore clippingPlanes // const {clippingPlanes, clippingPlanesOriginMatrix} = tileset; // if (clippingPlanes && clippingPlanes.enabled) { // const intersection = clippingPlanes.computeIntersectionWithBoundingVolume( // boundingVolume, // clippingPlanesOriginMatrix // ); // this._isClipped = intersection !== Intersect.INSIDE; // if (intersection === Intersect.OUTSIDE) { // return CullingVolume.MASK_OUTSIDE; // } // } // return cullingVolume.computeVisibilityWithPlaneMask(boundingVolume, parentVisibilityPlaneMask); return cullingVolume.computeVisibilityWithPlaneMask(boundingVolume, parentVisibilityPlaneMask); } // Assuming the tile's bounding volume intersects the culling volume, determines // whether the tile's content's bounding volume intersects the culling volume. // @param {FrameState} frameState The frame state. // @returns {Intersect} The result of the intersection: the tile's content is completely outside, completely inside, or intersecting the culling volume. contentVisibility() { return true; // TODO restore /* // Assumes the tile's bounding volume intersects the culling volume already, so // just return Intersect.INSIDE if there is no content bounding volume. if (!defined(this.contentBoundingVolume)) { return Intersect.INSIDE; } if (this._visibilityPlaneMask === CullingVolume.MASK_INSIDE) { // The tile's bounding volume is completely inside the culling volume so // the content bounding volume must also be inside. return Intersect.INSIDE; } // PERFORMANCE_IDEA: is it possible to burn less CPU on this test since we know the // tile's (not the content's) bounding volume intersects the culling volume? const cullingVolume = frameState.cullingVolume; const boundingVolume = tile.contentBoundingVolume; const tileset = this.tileset; const clippingPlanes = tileset.clippingPlanes; if (defined(clippingPlanes) && clippingPlanes.enabled) { const intersection = clippingPlanes.computeIntersectionWithBoundingVolume( boundingVolume, tileset.clippingPlanesOriginMatrix ); this._isClipped = intersection !== Intersect.INSIDE; if (intersection === Intersect.OUTSIDE) { return Intersect.OUTSIDE; } } return cullingVolume.computeVisibility(boundingVolume); */ } /** * Computes the (potentially approximate) distance from the closest point of the tile's bounding volume to the camera. * @param frameState The frame state. * @returns {Number} The distance, in meters, or zero if the camera is inside the bounding volume. */ distanceToTile(frameState) { const boundingVolume = this.boundingVolume; return Math.sqrt(Math.max(boundingVolume.distanceSquaredTo(frameState.camera.position), 0)); } /** * Computes the tile's camera-space z-depth. * @param frameState The frame state. * @returns The distance, in meters. */ cameraSpaceZDepth({ camera }) { const boundingVolume = this.boundingVolume; // Gets the underlying OrientedBoundingBox or BoundingSphere scratchVector.subVectors(boundingVolume.center, camera.position); return camera.direction.dot(scratchVector); } /** * Checks if the camera is inside the viewer request volume. * @param {FrameState} frameState The frame state. * @returns {Boolean} Whether the camera is inside the volume. */ insideViewerRequestVolume(frameState) { const viewerRequestVolume = this._viewerRequestVolume; return (!viewerRequestVolume || viewerRequestVolume.distanceSquaredTo(frameState.camera.position) <= 0); } // TODO Cesium specific // Update whether the tile has expired. updateExpiration() { if (defined(this._expireDate) && this.contentReady && !this.hasEmptyContent) { const now = Date.now(); // @ts-ignore Date.lessThan - replace with ms compare? if (Date.lessThan(this._expireDate, now)) { this.contentState = TILE_CONTENT_STATE.EXPIRED; this._expiredContent = this.content; } } } get extras() { return this.header.extras; } // INTERNAL METHODS _initializeLodMetric(header) { if ('lodMetricType' in header) { this.lodMetricType = header.lodMetricType; } else { this.lodMetricType = (this.parent && this.parent.lodMetricType) || this.tileset.lodMetricType; // eslint-disable-next-line console.warn(`3D Tile: Required prop lodMetricType is undefined. Using parent lodMetricType`); } // This is used to compute screen space error, i.e., the error measured in pixels. if ('lodMetricValue' in header) { this.lodMetricValue = header.lodMetricValue; } else { this.lodMetricValue = (this.parent && this.parent.lodMetricValue) || this.tileset.lodMetricValue; // eslint-disable-next-line console.warn('3D Tile: Required prop lodMetricValue is undefined. Using parent lodMetricValue'); } } _initializeTransforms(tileHeader) { // The local transform of this tile. this.transform = tileHeader.transform ? new Matrix4(tileHeader.transform) : new Matrix4(); const parent = this.parent; const tileset = this.tileset; const parentTransform = parent && parent.computedTransform ? parent.computedTransform.clone() : tileset.modelMatrix.clone(); this.computedTransform = new Matrix4(parentTransform).multiplyRight(this.transform); const parentInitialTransform = parent && parent._initialTransform ? parent._initialTransform.clone() : new Matrix4(); this._initialTransform = new Matrix4(parentInitialTransform).multiplyRight(this.transform); } _initializeBoundingVolumes(tileHeader) { this._contentBoundingVolume = null; this._viewerRequestVolume = null; this._updateBoundingVolume(tileHeader); } _initializeContent(tileHeader) { // Empty tile by default this.content = { _tileset: this.tileset, _tile: this }; this.hasEmptyContent = true; this.contentState = TILE_CONTENT_STATE.UNLOADED; // When `true`, the tile's content points to an external tileset. // This is `false` until the tile's content is loaded. this.hasTilesetContent = false; if (tileHeader.contentUrl) { this.content = null; this.hasEmptyContent = false; } } // TODO - remove anything not related to basic visibility detection _initializeRenderingState(header) { this.depth = header.level || (this.parent ? this.parent.depth + 1 : 0); this._shouldRefine = false; // Members this are updated every frame for tree traversal and rendering optimizations: this._distanceToCamera = 0; this._centerZDepth = 0; this._screenSpaceError = 0; this._visibilityPlaneMask = CullingVolume.MASK_INDETERMINATE; this._visible = undefined; this._inRequestVolume = false; this._stackLength = 0; this._selectionDepth = 0; this._frameNumber = 0; this._touchedFrame = 0; this._visitedFrame = 0; this._selectedFrame = 0; this._requestedFrame = 0; this._priority = 0.0; } _getRefine(refine) { // Inherit from parent tile if omitted. return refine || (this.parent && this.parent.refine) || TILE_REFINEMENT.REPLACE; } _isTileset() { return this.contentUrl.indexOf('.json') !== -1; } _onContentLoaded() { // Vector and Geometry tile rendering do not support the skip LOD optimization. switch (this.content && this.content.type) { case 'vctr': case 'geom': // @ts-ignore this.tileset._traverser.disableSkipLevelOfDetail = true; break; default: } // The content may be tileset json if (this._isTileset()) { this.hasTilesetContent = true; } else { this.gpuMemoryUsageInBytes = this._getGpuMemoryUsageInBytes(); } } _updateBoundingVolume(header) { // Update the bounding volumes this.boundingVolume = createBoundingVolume(header.boundingVolume, this.computedTransform, this.boundingVolume); const content = header.content; if (!content) { return; } // TODO Cesium specific // Non-leaf tiles may have a content bounding-volume, which is a tight-fit bounding volume // around only the features in the tile. This box is useful for culling for rendering, // but not for culling for traversing the tree since it does not guarantee spatial coherence, i.e., // since it only bounds features in the tile, not the entire tile, children may be // outside of this box. if (content.boundingVolume) { this._contentBoundingVolume = createBoundingVolume(content.boundingVolume, this.computedTransform, this._contentBoundingVolume); } if (header.viewerRequestVolume) { this._viewerRequestVolume = createBoundingVolume(header.viewerRequestVolume, this.computedTransform, this._viewerRequestVolume); } } // Update the tile's transform. The transform is applied to the tile's bounding volumes. _updateTransform(parentTransform = new Matrix4()) { const computedTransform = parentTransform.clone().multiplyRight(this.transform); const didTransformChange = !computedTransform.equals(this.computedTransform); if (!didTransformChange) { return; } this.computedTransform = computedTransform; this._updateBoundingVolume(this.header); } // Get options which are applicable only for the particular loader _getLoaderSpecificOptions(loaderId) { switch (loaderId) { case 'i3s': return { ...this.tileset.options.i3s, _tileOptions: { attributeUrls: this.header.attributeUrls, textureUrl: this.header.textureUrl, textureFormat: this.header.textureFormat, textureLoaderOptions: this.header.textureLoaderOptions, materialDefinition: this.header.materialDefinition, isDracoGeometry: this.header.isDracoGeometry, mbs: this.header.mbs }, _tilesetOptions: { store: this.tileset.tileset.store, attributeStorageInfo: this.tileset.tileset.attributeStorageInfo, fields: this.tileset.tileset.fields }, isTileHeader: false }; case '3d-tiles': case 'cesium-ion': default: return get3dTilesOptions(this.tileset.tileset); } } }