diff --git a/docs/api-reference/geo-layers/tile-layer.md b/docs/api-reference/geo-layers/tile-layer.md index f0326a28cfe..aefdc56833e 100644 --- a/docs/api-reference/geo-layers/tile-layer.md +++ b/docs/api-reference/geo-layers/tile-layer.md @@ -317,6 +317,19 @@ Apps may define a custom `refinementStrategy` by supplying its own callback func When called, the function receives an array of [Tile](#tile) instances representing every tile that is currently in the cache. It is an opportunity to manipulate `tile.isVisible` before sub layers are rendered. `isVisible` is initially set to the value of `isSelected` (equivalent to `refinementStrategy: 'never'`). +#### `lodStrategy` (string, optional) {#lodstrategy} + +Controls whether the layer prefetches lower-resolution tiles to provide instant coverage during viewport transitions (panning, zooming, or camera flights). + +* `'none'`: Only request tiles at the current zoom level. Tiles are prioritized by proximity to the viewport center, but no additional tiles are fetched. +* `'coverage'`: In addition to the current zoom tiles, prefetch ancestor tiles at progressively lower zoom levels. These low-resolution tiles load quickly and act as fallback coverage while higher-resolution tiles are still loading, reducing blank areas during navigation. + +Use `'coverage'` when your application involves frequent camera movement (e.g. animated transitions, user exploration) and visual continuity matters more than minimizing network requests. Use `'none'` when bandwidth is constrained, tiles are expensive to fetch, or the viewport is mostly static. + +Note: The `'coverage'` strategy is designed for geospatial views (e.g. `MapView`) where tiles follow a power-of-2 hierarchy. It is not recommended for non-geospatial views (e.g. `FirstPersonView`, `OrthographicView`) where viewport movement patterns differ and ancestor tiles may not provide meaningful coverage. + +- Default: `'none'` + #### `maxRequests` (number, optional) {#maxrequests} The maximum number of concurrent `getTileData` calls. diff --git a/examples/website/tile-priority-demo/app.tsx b/examples/website/tile-priority-demo/app.tsx new file mode 100644 index 00000000000..040d4f1fab6 --- /dev/null +++ b/examples/website/tile-priority-demo/app.tsx @@ -0,0 +1,557 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {DeckGL} from '@deck.gl/react'; +import {TileLayer, _Tileset2D as Tileset2D} from '@deck.gl/geo-layers'; +import {BitmapLayer, PathLayer, TextLayer} from '@deck.gl/layers'; +import {FlyToInterpolator, _GlobeView as GlobeView} from '@deck.gl/core'; + +type DemoMode = 'before' | 'after'; + +type TileRecord = { + id: string; + order: number; + status: 'loading' | 'loaded' | 'aborted'; +}; + +type DemoTile = { + id: string; + order: number; + image: ImageBitmap; +}; + +type ViewState = { + longitude: number; + latitude: number; + zoom: number; + pitch: number; + bearing: number; + maxZoom: number; +}; + +const MAPTILER_TOKEN = import.meta.env.VITE_MAPTILER_KEY; +const SATELLITE_TILE_URL = `https://api.maptiler.com/tiles/satellite-v2/{z}/{x}/{y}.jpg?key=${MAPTILER_TOKEN}`; + +const MIAMI_VIEW_STATE: ViewState = { + longitude: -80.1918, + latitude: 25.7617, + zoom: 12, + pitch: 32, + bearing: -14, + maxZoom: 20 +}; + +const MID_ROUTE_VIEW_STATE: ViewState = { + longitude: -77.04, + latitude: 35.32, + zoom: 8.8, + pitch: 78, + bearing: 34, + maxZoom: 20 +}; + +const NEW_YORK_VIEW_STATE: ViewState = { + longitude: -73.9851, + latitude: 40.7589, + zoom: 14.2, + pitch: 72, + bearing: 28, + maxZoom: 20 +}; + +const START_HOLD_MS = 5000; +const MID_ROUTE_DURATION_MS = 10000; +const NEW_YORK_DURATION_MS = 9000; +const FLY_DURATION_MS = MID_ROUTE_DURATION_MS + NEW_YORK_DURATION_MS; +const HOLD_DURATION_MS = 20000; +const MAX_REQUESTS = 6; + +const styles: Record = { + stage: { + position: 'fixed', + inset: 0, + display: 'grid', + gridTemplateColumns: '1fr 1fr', + background: '#07090b', + overflow: 'hidden' + }, + pane: { + position: 'relative', + minWidth: 0, + overflow: 'hidden' + }, + divider: { + position: 'fixed', + left: '50%', + top: 0, + bottom: 0, + width: 1, + background: 'linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.52), transparent)', + zIndex: 20 + }, + paneLabel: { + position: 'absolute', + top: 18, + left: 18, + zIndex: 10, + display: 'grid', + gap: 6, + minWidth: 188, + padding: '11px 12px', + border: '1px solid rgba(210, 228, 245, 0.18)', + borderRadius: 8, + background: 'linear-gradient(180deg, rgba(14, 20, 24, 0.8), rgba(7, 10, 13, 0.88))', + backdropFilter: 'blur(14px) saturate(135%)', + boxShadow: '0 18px 44px rgba(0, 0, 0, 0.34)', + color: '#f8fafc' + }, + labelTitle: { + display: 'flex', + alignItems: 'center', + gap: 8, + fontSize: 12, + fontWeight: 850 + }, + labelDot: { + width: 8, + height: 8, + borderRadius: '50%', + flex: '0 0 auto' + }, + labelSubtitle: { + color: '#a9b7c4', + fontSize: 11, + fontWeight: 650 + }, + labelStats: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: 6, + marginTop: 2, + fontVariantNumeric: 'tabular-nums' + }, + stat: { + border: '1px solid rgba(210, 228, 245, 0.13)', + borderRadius: 6, + padding: '6px 7px', + background: 'rgba(255, 255, 255, 0.05)' + }, + statLabel: { + color: '#96a5b3', + fontSize: 9, + fontWeight: 800 + }, + statValue: { + color: '#fff6b8', + fontSize: 16, + fontWeight: 850 + }, + routeBadge: { + position: 'fixed', + left: '50%', + top: 18, + transform: 'translateX(-50%)', + zIndex: 30, + padding: '9px 14px', + border: '1px solid rgba(210, 228, 245, 0.2)', + borderRadius: 999, + background: 'rgba(7, 10, 13, 0.78)', + backdropFilter: 'blur(14px) saturate(135%)', + color: '#f8fafc', + fontSize: 12, + fontWeight: 800, + boxShadow: '0 14px 36px rgba(0, 0, 0, 0.36)', + fontVariantNumeric: 'tabular-nums' + }, + progressTrack: { + position: 'fixed', + left: '50%', + bottom: 18, + transform: 'translateX(-50%)', + zIndex: 30, + width: 360, + height: 5, + borderRadius: 999, + background: 'rgba(255, 255, 255, 0.16)', + overflow: 'hidden' + }, + progressFill: { + height: '100%', + borderRadius: 999, + background: 'linear-gradient(90deg, #fff06a, #67c4ff)' + }, + crosshair: { + position: 'absolute', + left: '50%', + top: '50%', + zIndex: 9, + width: 34, + height: 34, + marginLeft: -17, + marginTop: -17, + border: '2px solid #fff6b8', + borderRadius: '50%', + pointerEvents: 'none' + }, + crosshairLineH: { + position: 'absolute', + left: -18, + top: 15, + width: 70, + height: 2, + background: '#fff6b8' + }, + crosshairLineV: { + position: 'absolute', + left: 15, + top: -18, + width: 2, + height: 70, + background: '#fff6b8' + } +}; + +const LegacyPriorityTileset2D = class extends (Tileset2D as any) { + _getRequestPriority(tile): number { + if (tile.isSelected) { + return 0; + } + if (tile.isVisible) { + return 1e6; + } + return -1; + } +} as typeof Tileset2D; + +function getOrderColor(mode: DemoMode, order: number): [number, number, number, number] { + if (mode === 'before') { + return order <= 4 ? [255, 143, 82, 230] : [130, 171, 202, 205]; + } + if (order <= 4) { + return [255, 240, 106, 235]; + } + if (order <= 8) { + return [103, 196, 255, 220]; + } + return [149, 223, 185, 205]; +} + +function getTileCenter({ + bbox +}: { + bbox: {west: number; south: number; east: number; north: number}; +}): [number, number] { + return [(bbox.west + bbox.east) / 2, (bbox.south + bbox.north) / 2]; +} + +function getTilePath({ + bbox +}: { + bbox: {west: number; south: number; east: number; north: number}; +}): number[][] { + const {west, south, east, north} = bbox; + return [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south] + ]; +} + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + window.clearTimeout(timeout); + reject(new Error('Tile request aborted')); + }, + {once: true} + ); + }); +} + +function interpolateFlyToViewState(start: ViewState, end: ViewState, t: number): ViewState { + const interpolator = new FlyToInterpolator({curve: 1.15, speed: 0.65}); + const width = Math.max(1, window.innerWidth / 2); + const height = Math.max(1, window.innerHeight); + const easedT = easeInOutCubic(t); + const viewport = interpolator.interpolateProps( + {...start, width, height}, + {...end, width, height}, + easedT + ) as ViewState; + + return { + longitude: viewport.longitude, + latitude: viewport.latitude, + zoom: viewport.zoom, + pitch: viewport.pitch, + bearing: viewport.bearing, + maxZoom: end.maxZoom + }; +} + +function easeInOutCubic(t: number): number { + const clampedT = Math.max(0, Math.min(t, 1)); + return clampedT < 0.5 ? 4 * clampedT ** 3 : 1 - (-2 * clampedT + 2) ** 3 / 2; +} + +function makeTileId(url: string): string { + return url.match(/satellite-v2\/(\d+\/\d+\/\d+)/)?.[1].replaceAll('/', '-') || 'tile'; +} + +function DemoPane({ + mode, + viewState, + generation, + title, + subtitle +}: { + mode: DemoMode; + viewState: ViewState; + generation: number; + title: string; + subtitle: string; +}) { + const [records, setRecords] = useState([]); + const requestOrderRef = useRef(0); + const generationRef = useRef(generation); + + useEffect(() => { + generationRef.current = generation; + requestOrderRef.current = 0; + setRecords([]); + }, [generation]); + + const fetchTile = useCallback(async (url: string, {signal}: {signal?: AbortSignal}) => { + const requestGeneration = generationRef.current; + const id = makeTileId(url); + const order = ++requestOrderRef.current; + + setRecords(current => [ + {id, order, status: 'loading'}, + ...current.filter(item => item.id !== id) + ]); + + try { + const response = await fetch(url, {signal}); + if (!response.ok) { + throw new Error(`Tile request failed: ${response.status}`); + } + const blob = await response.blob(); + const image = await createImageBitmap(blob); + await wait(250, signal); + setRecords(current => + requestGeneration === generationRef.current + ? current.map(item => (item.id === id ? {...item, status: 'loaded'} : item)) + : current + ); + return {id, order, image}; + } catch (error) { + setRecords(current => + requestGeneration === generationRef.current + ? current.map(item => (item.id === id ? {...item, status: 'aborted'} : item)) + : current + ); + throw error; + } + }, []); + + const layer = useMemo( + () => + new TileLayer({ + id: `${mode}-tiles`, + TilesetClass: mode === 'before' ? LegacyPriorityTileset2D : Tileset2D, + minZoom: 0, + maxZoom: 20, + tileSize: 512, + maxRequests: MAX_REQUESTS, + refinementStrategy: 'best-available', + lodStrategy: mode === 'after' ? 'coverage' : 'none', + data: [SATELLITE_TILE_URL], + fetch: fetchTile, + renderSubLayers: props => { + const {data, tile} = props; + if (!data) { + return null; + } + const {west, south, east, north} = tile.bbox; + return [ + new BitmapLayer(props, { + id: `${props.id}-satellite`, + image: data.image, + bounds: [west, south, east, north] + }), + new PathLayer(props, { + id: `${props.id}-priority-border`, + data: [{bbox: tile.bbox, order: data.order}], + getPath: getTilePath, + getColor: d => getOrderColor(mode, d.order), + widthMinPixels: mode === 'after' ? 3 : 2 + }), + new TextLayer(props, { + id: `${props.id}-label`, + data: [data], + getPosition: () => getTileCenter(tile), + getText: d => `#${d.order}`, + getSize: 28, + getColor: d => getOrderColor(mode, d.order), + getBackgroundColor: [6, 10, 14, 215], + background: true, + backgroundPadding: [7, 4], + getTextAnchor: 'middle', + getAlignmentBaseline: 'center' + }) + ]; + } + }), + [fetchTile, generation, mode] + ); + + const loadingCount = records.filter(record => record.status === 'loading').length; + const loadedCount = records.filter(record => record.status === 'loaded').length; + const dotColor = mode === 'before' ? '#ff8f52' : '#fff06a'; + + return ( +
+ +
+
+
+
+
+
+ + {title} +
+
{subtitle}
+
+
+
Loading
+
{loadingCount}
+
+
+
Loaded
+
{loadedCount}
+
+
+
+
+ ); +} + +export default function App() { + const [viewState, setViewState] = useState(MIAMI_VIEW_STATE); + const [progress, setProgress] = useState(0); + const [generation, setGeneration] = useState(0); + const generationRef = useRef(0); + const loopStartTimeRef = useRef(performance.now()); + + const startLoop = useCallback((now = performance.now()) => { + const nextGeneration = generationRef.current + 1; + generationRef.current = nextGeneration; + loopStartTimeRef.current = now; + + setGeneration(nextGeneration); + setProgress(0); + setViewState({...MIAMI_VIEW_STATE}); + }, []); + + useEffect(() => { + let frameId = 0; + const loopDuration = START_HOLD_MS + FLY_DURATION_MS + HOLD_DURATION_MS; + + const frame = (now: number) => { + let elapsed = now - loopStartTimeRef.current; + if (elapsed >= loopDuration) { + startLoop(now); + elapsed = 0; + } + + const flightElapsed = elapsed - START_HOLD_MS; + const nextProgress = Math.max(0, Math.min(flightElapsed / FLY_DURATION_MS, 1)); + setProgress(nextProgress); + + if (flightElapsed <= 0) { + setViewState({...MIAMI_VIEW_STATE}); + } else if (flightElapsed < MID_ROUTE_DURATION_MS) { + setViewState( + interpolateFlyToViewState( + MIAMI_VIEW_STATE, + MID_ROUTE_VIEW_STATE, + flightElapsed / MID_ROUTE_DURATION_MS + ) + ); + } else if (flightElapsed < FLY_DURATION_MS) { + setViewState( + interpolateFlyToViewState( + MID_ROUTE_VIEW_STATE, + NEW_YORK_VIEW_STATE, + (flightElapsed - MID_ROUTE_DURATION_MS) / NEW_YORK_DURATION_MS + ) + ); + } else { + setViewState({...NEW_YORK_VIEW_STATE}); + } + + frameId = requestAnimationFrame(frame); + }; + + startLoop(); + frameId = requestAnimationFrame(frame); + return () => { + cancelAnimationFrame(frameId); + }; + }, [startLoop]); + + return ( +
+ + +
+
+ Miami to New York | Pitch {Math.round(viewState.pitch)} | Bearing{' '} + {Math.round(viewState.bearing)} +
+
+
+
+
+ ); +} + +export function renderToDOM(container: HTMLDivElement) { + createRoot(container).render(); +} + +const container = document.getElementById('app'); + +if (container) { + renderToDOM(container as HTMLDivElement); +} diff --git a/examples/website/tile-priority-demo/index.html b/examples/website/tile-priority-demo/index.html new file mode 100644 index 00000000000..9169f85983e --- /dev/null +++ b/examples/website/tile-priority-demo/index.html @@ -0,0 +1,26 @@ + + + + + Tile request priority demo + + + +
+ + + diff --git a/modules/geo-layers/src/terrain-layer/terrain-layer.ts b/modules/geo-layers/src/terrain-layer/terrain-layer.ts index 74eefb62696..41bb77f2aee 100644 --- a/modules/geo-layers/src/terrain-layer/terrain-layer.ts +++ b/modules/geo-layers/src/terrain-layer/terrain-layer.ts @@ -355,7 +355,8 @@ export default class TerrainLayer extends Composite onTileError, maxCacheSize, maxCacheByteSize, - refinementStrategy + refinementStrategy, + lodStrategy } = this.props; if (this.state.isTiled) { @@ -387,7 +388,8 @@ export default class TerrainLayer extends Composite onTileError, maxCacheSize, maxCacheByteSize, - refinementStrategy + refinementStrategy, + lodStrategy } ); } diff --git a/modules/geo-layers/src/tile-layer/tile-layer.ts b/modules/geo-layers/src/tile-layer/tile-layer.ts index de5e6b3a8fb..ce9f655345b 100644 --- a/modules/geo-layers/src/tile-layer/tile-layer.ts +++ b/modules/geo-layers/src/tile-layer/tile-layer.ts @@ -22,6 +22,7 @@ import { Tileset2D, Tile2DHeader, RefinementStrategy, + LODStrategy, STRATEGY_DEFAULT, Tileset2DProps } from '../tileset-2d/index'; @@ -47,6 +48,7 @@ const defaultProps: DefaultProps = { maxCacheSize: null, maxCacheByteSize: null, refinementStrategy: STRATEGY_DEFAULT, + lodStrategy: 'none', zRange: null, maxRequests: 6, debounceTime: 0, @@ -126,6 +128,13 @@ type _TileLayerProps = { */ refinementStrategy?: RefinementStrategy; + /** + * How the tile layer prefetches lower resolution coverage for smooth transitions. + * + * @default 'none' + */ + lodStrategy?: LODStrategy; + /** Range of minimum and maximum heights in the tile. */ zRange?: ZRange | null; @@ -261,6 +270,7 @@ export default class TileLayer extends maxCacheSize, maxCacheByteSize, refinementStrategy, + lodStrategy, extent, maxZoom, minZoom, @@ -278,6 +288,7 @@ export default class TileLayer extends minZoom, tileSize, refinementStrategy, + lodStrategy, extent, maxRequests, debounceTime, diff --git a/modules/geo-layers/src/tileset-2d/index.ts b/modules/geo-layers/src/tileset-2d/index.ts index d6ed77d3ccc..dbfe01fd3e8 100644 --- a/modules/geo-layers/src/tileset-2d/index.ts +++ b/modules/geo-layers/src/tileset-2d/index.ts @@ -13,8 +13,8 @@ export type { TileBoundingBox } from './types'; -export type {Tileset2DProps, RefinementStrategy} from './tileset-2d'; -export {Tileset2D, STRATEGY_DEFAULT} from './tileset-2d'; +export type {Tileset2DProps, RefinementStrategy, LODStrategy} from './tileset-2d'; +export {Tileset2D, STRATEGY_DEFAULT, LOD_STRATEGY_COVERAGE, LOD_STRATEGY_NONE} from './tileset-2d'; export {Tile2DHeader} from './tile-2d-header'; diff --git a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts index 32406256cbc..6e4e5f662eb 100644 --- a/modules/geo-layers/src/tileset-2d/tile-2d-header.ts +++ b/modules/geo-layers/src/tileset-2d/tile-2d-header.ts @@ -10,6 +10,7 @@ import type {Layer} from '@deck.gl/core'; export type TileLoadDataProps = { requestScheduler: RequestScheduler; getData: (props: TileLoadProps) => Promise; + getPriority: (tile: Tile2DHeader) => number; onLoad: (tile: Tile2DHeader) => void; onError: (error: any, tile: Tile2DHeader) => void; }; @@ -18,6 +19,7 @@ export class Tile2DHeader { index: TileIndex; isVisible: boolean; isSelected: boolean; + isPrefetch: boolean; parent: Tile2DHeader | null; children: Tile2DHeader[] | null; content: DataT | null; @@ -41,6 +43,7 @@ export class Tile2DHeader { this.index = index; this.isVisible = false; this.isSelected = false; + this.isPrefetch = false; this.parent = null; this.children = []; @@ -106,6 +109,7 @@ export class Tile2DHeader { /* eslint-disable max-statements */ private async _loadData({ getData, + getPriority, requestScheduler, onLoad, onError @@ -116,10 +120,8 @@ export class Tile2DHeader { this._abortController = new AbortController(); const {signal} = this._abortController; - // @ts-expect-error (2345) Argument of type '(tile: any) => 1 | -1' is not assignable ... - const requestToken = await requestScheduler.scheduleRequest(this, tile => { - return tile.isSelected ? 1 : -1; - }); + // @ts-expect-error (2345) loaders.gl's RequestScheduler callback type is too narrow. + const requestToken = await requestScheduler.scheduleRequest(this, getPriority); if (!requestToken) { this._isCancelled = true; diff --git a/modules/geo-layers/src/tileset-2d/tileset-2d.ts b/modules/geo-layers/src/tileset-2d/tileset-2d.ts index e0752ec13a8..c5afd09791e 100644 --- a/modules/geo-layers/src/tileset-2d/tileset-2d.ts +++ b/modules/geo-layers/src/tileset-2d/tileset-2d.ts @@ -10,7 +10,7 @@ import {Matrix4, equals, NumericArray} from '@math.gl/core'; import {Tile2DHeader} from './tile-2d-header'; import {getTileIndices, tileToBoundingBox, getCullBounds, transformBox} from './utils'; -import {Bounds, TileIndex, ZRange} from './types'; +import {Bounds, TileBoundingBox, TileIndex, ZRange} from './types'; import {TileLoadProps} from './types'; import {memoize} from './memoize'; @@ -39,6 +39,8 @@ const TILE_STATE_VISIBLE = 2; export const STRATEGY_NEVER = 'never'; export const STRATEGY_REPLACE = 'no-overlap'; export const STRATEGY_DEFAULT = 'best-available'; +export const LOD_STRATEGY_NONE = 'none'; +export const LOD_STRATEGY_COVERAGE = 'coverage'; export type RefinementStrategyFunction = (tiles: Tile2DHeader[]) => void; export type RefinementStrategy = @@ -46,8 +48,14 @@ export type RefinementStrategy = | 'no-overlap' | 'best-available' | RefinementStrategyFunction; +export type LODStrategy = 'none' | 'coverage'; const DEFAULT_CACHE_SCALE = 5; +const COVERAGE_ZOOM_DELTA = 2; +const MIN_COVERAGE_ZOOM = 4; +const SELECTED_TILE_PRIORITY = 0; +const VISIBLE_TILE_PRIORITY = 1e8; +const PREFETCH_TILE_PRIORITY = 2e8; const STRATEGIES = { [STRATEGY_DEFAULT]: updateTileStateDefault, @@ -73,6 +81,8 @@ export type Tileset2DProps = { maxCacheByteSize?: number | null; /** How the tile layer refines the visibility of tiles. @default 'best-available' */ refinementStrategy?: RefinementStrategy; + /** How the tile layer prefetches lower resolution coverage. @default 'none' */ + lodStrategy?: LODStrategy; /** Range of minimum and maximum heights in the tile. */ zRange?: ZRange | null; /** The maximum number of concurrent getTileData calls. @default 6 */ @@ -108,6 +118,7 @@ export const DEFAULT_TILESET2D_PROPS: Omit, 'getTileDat maxCacheSize: null, maxCacheByteSize: null, refinementStrategy: 'best-available', + lodStrategy: 'none', zRange: null, maxRequests: 6, debounceTime: 0, @@ -137,6 +148,7 @@ export class Tileset2D { private _viewport: Viewport | null; private _zRange: ZRange | null; private _selectedTiles: Tile2DHeader[] | null; + private _prefetchTiles: Tile2DHeader[]; private _frameNumber: number; private _modelMatrix: Matrix4; private _modelMatrixInverse: Matrix4; @@ -178,6 +190,7 @@ export class Tileset2D { this._viewport = null; this._zRange = null; this._selectedTiles = null; + this._prefetchTiles = []; this._frameNumber = 0; this._modelMatrix = new Matrix4(); @@ -269,6 +282,7 @@ export class Tileset2D { modelMatrixInverse: this._modelMatrixInverse }); this._selectedTiles = tileIndices.map(index => this._getTile(index, true)); + this._updatePrefetchTiles(); if (this._dirty) { // Some new tiles are added @@ -277,6 +291,7 @@ export class Tileset2D { // Check for needed reloads explicitly even if the view/matrix has not changed. } else if (this.needsReload) { this._selectedTiles = this._selectedTiles!.map(tile => this._getTile(tile.index, true)); + this._updatePrefetchTiles(); } // Update tile states @@ -410,12 +425,16 @@ export class Tileset2D { visibilities[i++] = tile.isVisible; tile.isSelected = false; tile.isVisible = false; + tile.isPrefetch = false; } // @ts-expect-error called only when _selectedTiles is already defined for (const tile of this._selectedTiles) { tile.isSelected = true; tile.isVisible = true; } + for (const tile of this._prefetchTiles) { + tile.isPrefetch = true; + } // Strategy-specific state logic (typeof refinementStrategy === 'function' @@ -437,6 +456,105 @@ export class Tileset2D { private _getCullBounds = memoize(getCullBounds); + private _getRequestPriority(tile: Tile2DHeader): number { + // RequestScheduler loads lower priority values first. + const distance = this._getTileDistanceSquared(tile); + if (tile.isSelected) { + return SELECTED_TILE_PRIORITY + distance; + } + if (tile.isVisible) { + return VISIBLE_TILE_PRIORITY + distance; + } + if (tile.isPrefetch) { + return ( + PREFETCH_TILE_PRIORITY + this.getTileZoom(tile.index) * PREFETCH_TILE_PRIORITY + distance + ); + } + return -1; + } + + private _getTileDistanceSquared(tile: Tile2DHeader): number { + const {width, height} = this._viewport || {}; + if (!this._viewport || !width || !height) { + return 0; + } + + try { + const points = this._getTileScreenCorners(tile.bbox); + const center: [number, number] = [width / 2, height / 2]; + if (points.length === 4) { + if (this._isPointInPolygon(center, points)) { + return 0; + } + return points.reduce((minDistance, point, i) => { + const nextPoint = points[(i + 1) % points.length]; + return Math.min( + minDistance, + this._getPointToSegmentDistanceSquared(center, point, nextPoint) + ); + }, Number.MAX_SAFE_INTEGER); + } + } catch { + // Some viewport/tile combinations are not projectable. Keep them valid but lowest priority. + } + return Number.MAX_SAFE_INTEGER; + } + + private _getTileScreenCorners(bbox: TileBoundingBox): [number, number][] { + const coordinates: [number, number][] = + 'west' in bbox + ? [ + [bbox.west, bbox.south], + [bbox.east, bbox.south], + [bbox.east, bbox.north], + [bbox.west, bbox.north] + ] + : [ + [bbox.left, bbox.top], + [bbox.right, bbox.top], + [bbox.right, bbox.bottom], + [bbox.left, bbox.bottom] + ]; + + return coordinates + .map(coordinate => this._viewport!.project(coordinate)) + .filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)) as [number, number][]; + } + + private _isPointInPolygon(point: [number, number], polygon: [number, number][]): boolean { + let inside = false; + const [x, y] = point; + for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) { + const [xi, yi] = polygon[i]; + const [xj, yj] = polygon[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) { + inside = !inside; + } + } + return inside; + } + + private _getPointToSegmentDistanceSquared( + point: [number, number], + segmentStart: [number, number], + segmentEnd: [number, number] + ): number { + const [x, y] = point; + const [x1, y1] = segmentStart; + const [x2, y2] = segmentEnd; + const dx = x2 - x1; + const dy = y2 - y1; + const lengthSquared = dx * dx + dy * dy; + const t = lengthSquared + ? Math.max(0, Math.min(1, ((x - x1) * dx + (y - y1) * dy) / lengthSquared)) + : 0; + const segmentX = x1 + t * dx; + const segmentY = y1 + t * dy; + const distanceX = x - segmentX; + const distanceY = y - segmentY; + return distanceX * distanceX + distanceY * distanceY; + } + private _pruneRequests(): void { const {maxRequests = 0} = this.opts; @@ -446,7 +564,7 @@ export class Tileset2D { // Keep track of all the ongoing requests if (tile.isLoading) { ongoingRequestCount++; - if (!tile.isSelected && !tile.isVisible) { + if (!tile.isSelected && !tile.isVisible && !tile.isPrefetch) { abortCandidates.push(tile); } } @@ -498,16 +616,10 @@ export class Tileset2D { const overflown = _cache.size > maxCacheSize || this._cacheByteSize > maxCacheByteSize; if (overflown) { - for (const [id, tile] of _cache) { - if (!tile.isVisible && !tile.isSelected) { - // delete tile - this._cacheByteSize -= opts.maxCacheByteSize !== null ? tile.byteLength : 0; - _cache.delete(id); - this.opts.onTileUnload?.(tile); - } - if (_cache.size <= maxCacheSize && this._cacheByteSize <= maxCacheByteSize) { - break; - } + this._evictTiles(tile => !tile.isVisible && !tile.isSelected && !tile.isPrefetch); + + if (_cache.size > maxCacheSize || this._cacheByteSize > maxCacheByteSize) { + this._evictTiles(tile => !tile.isVisible && !tile.isSelected); } this._rebuildTree(); this._dirty = true; @@ -521,6 +633,26 @@ export class Tileset2D { } /* eslint-enable complexity */ + private _evictTiles(shouldEvict: (tile: Tile2DHeader) => boolean): void { + const {_cache, opts} = this; + const maxCacheSize = + opts.maxCacheSize ?? + // @ts-expect-error called only when selectedTiles is initialized + (opts.maxCacheByteSize !== null ? Infinity : DEFAULT_CACHE_SCALE * this.selectedTiles.length); + const maxCacheByteSize = opts.maxCacheByteSize ?? Infinity; + + for (const [id, tile] of _cache) { + if (shouldEvict(tile)) { + this._cacheByteSize -= opts.maxCacheByteSize !== null ? tile.byteLength : 0; + _cache.delete(id); + this.opts.onTileUnload?.(tile); + } + if (_cache.size <= maxCacheSize && this._cacheByteSize <= maxCacheByteSize) { + break; + } + } + } + private _getTile(index: TileIndex, create: true): Tile2DHeader; private _getTile(index: TileIndex, create?: false): Tile2DHeader | undefined; private _getTile(index: TileIndex, create?: boolean): Tile2DHeader | undefined { @@ -542,6 +674,7 @@ export class Tileset2D { // eslint-disable-next-line @typescript-eslint/no-floating-promises tile.loadData({ getData: this.opts.getTileData, + getPriority: this._getRequestPriority.bind(this), requestScheduler: this._requestScheduler, onLoad: this.onTileLoad, onError: this.opts.onTileError @@ -551,6 +684,54 @@ export class Tileset2D { return tile; } + private _updatePrefetchTiles(): void { + this._prefetchTiles = []; + if (this.opts.lodStrategy !== LOD_STRATEGY_COVERAGE || !this._selectedTiles) { + return; + } + + const minZoom = this._getMinCoverageZoom(); + const seen = new Set(); + for (const selectedTile of this._selectedTiles) { + const selectedZoom = this.getTileZoom(selectedTile.index); + if (selectedZoom <= minZoom) { + continue; + } + + const coverageZoom = Math.max(minZoom, selectedZoom - COVERAGE_ZOOM_DELTA); + for (const zoom of [coverageZoom, minZoom]) { + const index = this._getAncestorIndex(selectedTile.index, zoom); + const id = this.getTileId(index); + if (seen.has(id)) { + continue; + } + seen.add(id); + + const tile = this._getTile(index, true); + if (tile && !tile.isSelected) { + tile.isPrefetch = true; + this._prefetchTiles.push(tile); + } + } + } + } + + private _getAncestorIndex(index: TileIndex, zoom: number): TileIndex { + let ancestor = index; + while (this.getTileZoom(ancestor) > zoom) { + ancestor = this.getParentIndex(ancestor); + } + return ancestor; + } + + private _getMinCoverageZoom(): number { + const minZoom = this._minZoom ?? 0; + if (this._viewport?.resolution) { + return Math.max(minZoom, MIN_COVERAGE_ZOOM); + } + return minZoom; + } + _getNearestAncestor(tile: Tile2DHeader): Tile2DHeader | null { const {_minZoom = 0} = this; diff --git a/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts b/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts index 578ea3b595d..a7a7e2124a1 100644 --- a/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts +++ b/test/modules/geo-layers/tileset-2d/tile-2d-header.spec.ts @@ -6,6 +6,8 @@ import {test, expect} from 'vitest'; import {_Tile2DHeader as Tile2DHeader} from '@deck.gl/geo-layers'; import {RequestScheduler} from '@loaders.gl/loader-utils'; +const getPriority = tile => (tile.isSelected ? 1 : -1); + test('Tile2DHeader', async () => { let onTileLoadCalled = false; let onTileErrorCalled = false; @@ -14,6 +16,7 @@ test('Tile2DHeader', async () => { let tile2d = new Tile2DHeader({}); await tile2d.loadData({ requestScheduler, + getPriority, getData: () => 'loaded data', onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -26,6 +29,7 @@ test('Tile2DHeader', async () => { tile2d = new Tile2DHeader({}); await tile2d.loadData({ requestScheduler, + getPriority, getData: () => { throw new Error('getTileData error'); }, @@ -44,6 +48,7 @@ test('Tile2DHeader#Cancel request if not selected', async () => { const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); const opts = { requestScheduler, + getPriority, getData: () => tileRequestCount++, onLoad: () => onTileLoadCalled++, onError: () => onTileErrorCalled++ @@ -66,6 +71,35 @@ test('Tile2DHeader#Cancel request if not selected', async () => { expect(onTileLoadCalled === 1 && onTileErrorCalled === 0, 'Callbacks invoked').toBeTruthy(); }); +test('Tile2DHeader#request priority', async () => { + const requestOrder: string[] = []; + const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); + const opts = { + requestScheduler, + getPriority: tile => (tile.id === 'center' ? 0 : 10), + getData: ({id}) => { + requestOrder.push(id); + return id; + }, + onLoad: () => {}, + onError: () => {} + }; + + const edgeTile = new Tile2DHeader({}); + edgeTile.id = 'edge'; + edgeTile.isSelected = true; + const centerTile = new Tile2DHeader({}); + centerTile.id = 'center'; + centerTile.isSelected = true; + + const edgeLoader = edgeTile.loadData(opts); + const centerLoader = centerTile.loadData(opts); + await edgeLoader; + await centerLoader; + + expect(requestOrder, 'lower request priority values load first').toEqual(['center', 'edge']); +}); + test('Tile2DHeader#abort', async () => { const requestScheduler = new RequestScheduler({throttleRequests: true, maxRequests: 1}); let onTileLoadCalled = false; @@ -73,6 +107,7 @@ test('Tile2DHeader#abort', async () => { const opts = { requestScheduler, + getPriority, getData: () => null, onLoad: () => (onTileLoadCalled = true), onError: () => (onTileErrorCalled = true) @@ -104,6 +139,7 @@ test('Tile2DHeader#reload', async () => { let onTileErrorCalled = 0; const opts = { requestScheduler, + getPriority, onLoad: () => onTileLoadCalled++, onError: () => onTileErrorCalled++ }; diff --git a/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts b/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts index 1f14df8e444..113b2011b22 100644 --- a/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts +++ b/test/modules/geo-layers/tileset-2d/tileset-2d.spec.ts @@ -55,6 +55,69 @@ test('Tileset2D#update', () => { expect(tileset.tiles[0].bbox, 'tile has metadata').toBeTruthy(); }); +test('Tileset2D#update with coverage LOD', () => { + const tileset = new Tileset2D({ + getTileData, + lodStrategy: 'coverage', + onTileLoad: () => {} + }); + tileset.update(testViewport); + + expect(tileset._cache.get('0-0-0')?.isPrefetch, 'root coverage tile is prefetched').toBe(true); + expect( + tileset._cache.get('292-391-10')?.isPrefetch, + 'lower resolution coverage tile is prefetched' + ).toBe(true); + expect(tileset._cache.get('1171-1566-12')?.isSelected, 'target tile remains selected').toBe(true); + + tileset.finalize(); +}); + +test('Tileset2D#getRequestPriority ranks tiles by viewport coverage', () => { + const tileset = new Tileset2D({ + getTileData, + onTileLoad: () => {} + }); + Object.assign(tileset, { + _viewport: { + width: 100, + height: 100, + project: ([x, y]) => [x, y] + } + }); + + const selectedAtCenterEdge = { + bbox: {left: 0, top: 0, right: 50, bottom: 100}, + index: {x: 0, y: 0, z: 10}, + isSelected: true, + isVisible: true, + isPrefetch: false + }; + const selectedNearCenter = { + bbox: {left: 60, top: 45, right: 70, bottom: 55}, + index: {x: 1, y: 0, z: 10}, + isSelected: true, + isVisible: true, + isPrefetch: false + }; + const prefetchAtCenter = { + bbox: {left: 0, top: 0, right: 100, bottom: 100}, + index: {x: 0, y: 0, z: 8}, + isSelected: false, + isVisible: false, + isPrefetch: true + }; + + expect((tileset as any)._getRequestPriority(selectedAtCenterEdge)).toBeLessThan( + (tileset as any)._getRequestPriority(selectedNearCenter) + ); + expect((tileset as any)._getRequestPriority(selectedNearCenter)).toBeLessThan( + (tileset as any)._getRequestPriority(prefetchAtCenter) + ); + + tileset.finalize(); +}); + test('Tileset2D#updateOnModelMatrix', () => { const tileset = new Tileset2D({ getTileData,