UNPKG

@deck.gl/core

Version:

deck.gl core library

553 lines (488 loc) 15.9 kB
// deck.gl // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors import type {Device} from '@luma.gl/core'; import PickLayersPass, {PickingColorDecoder} from '../passes/pick-layers-pass'; import {getClosestObject, getUniqueObjects, PickedPixel} from './picking/query-object'; import { processPickInfo, getLayerPickingInfo, getEmptyPickingInfo, PickingInfo } from './picking/pick-info'; import type {Framebuffer} from '@luma.gl/core'; import type {FilterContext, Rect} from '../passes/layers-pass'; import type Layer from './layer'; import type {Effect} from './effect'; import type View from '../views/view'; import type Viewport from '../viewports/viewport'; export type PickByPointOptions = { x: number; y: number; radius?: number; depth?: number; mode?: string; unproject3D?: boolean; }; export type PickByRectOptions = { x: number; y: number; width?: number; height?: number; mode?: string; maxObjects?: number | null; }; type PickOperationContext = { layers: Layer[]; views: Record<string, View>; viewports: Viewport[]; onViewportActive: (viewport: Viewport) => void; effects: Effect[]; }; /** Manages picking in a Deck context */ export default class DeckPicker { device: Device; pickingFBO?: Framebuffer; depthFBO?: Framebuffer; pickLayersPass: PickLayersPass; layerFilter?: (context: FilterContext) => boolean; /** Identifiers of the previously picked object, for callback tracking and auto highlight */ lastPickedInfo: { index: number; layerId: string | null; info: PickingInfo | null; }; _pickable: boolean = true; constructor(device: Device) { this.device = device; this.pickLayersPass = new PickLayersPass(device); this.lastPickedInfo = { index: -1, layerId: null, info: null }; } setProps(props: any): void { if ('layerFilter' in props) { this.layerFilter = props.layerFilter; } if ('_pickable' in props) { this._pickable = props._pickable; } } finalize() { if (this.pickingFBO) { this.pickingFBO.destroy(); } if (this.depthFBO) { this.depthFBO.destroy(); } } /** Pick the closest info at given coordinate */ pickObject(opts: PickByPointOptions & PickOperationContext) { return this._pickClosestObject(opts); } /** Get all unique infos within a bounding box */ pickObjects(opts: PickByRectOptions & PickOperationContext) { return this._pickVisibleObjects(opts); } // Returns a new picking info object by assuming the last picked object is still picked getLastPickedObject({x, y, layers, viewports}, lastPickedInfo = this.lastPickedInfo.info) { const lastPickedLayerId = lastPickedInfo && lastPickedInfo.layer && lastPickedInfo.layer.id; const lastPickedViewportId = lastPickedInfo && lastPickedInfo.viewport && lastPickedInfo.viewport.id; const layer = lastPickedLayerId ? layers.find(l => l.id === lastPickedLayerId) : null; const viewport = (lastPickedViewportId && viewports.find(v => v.id === lastPickedViewportId)) || viewports[0]; const coordinate = viewport && viewport.unproject([x - viewport.x, y - viewport.y]); const info = { x, y, viewport, coordinate, layer }; return {...lastPickedInfo, ...info}; } // Private /** Ensures that picking framebuffer exists and matches the canvas size */ _resizeBuffer() { // Create a frame buffer if not already available if (!this.pickingFBO) { this.pickingFBO = this.device.createFramebuffer({ colorAttachments: ['rgba8unorm'], depthStencilAttachment: 'depth16unorm' }); if (this.device.isTextureFormatRenderable('rgba32float')) { const depthFBO = this.device.createFramebuffer({ colorAttachments: ['rgba32float'], depthStencilAttachment: 'depth16unorm' }); this.depthFBO = depthFBO; } } // Resize it to current canvas size (this is a noop if size hasn't changed) const {canvas} = this.device.getDefaultCanvasContext(); this.pickingFBO?.resize({width: canvas.width, height: canvas.height}); this.depthFBO?.resize({width: canvas.width, height: canvas.height}); } /** Preliminary filtering of the layers list. Skid picking pass if no layer is pickable. */ _getPickable(layers: Layer[]): Layer[] | null { if (this._pickable === false) { return null; } const pickableLayers = layers.filter( layer => this.pickLayersPass.shouldDrawLayer(layer) && !layer.isComposite ); return pickableLayers.length ? pickableLayers : null; } /** Pick the closest object at the given coordinate */ // eslint-disable-next-line max-statements,complexity _pickClosestObject({ layers, views, viewports, x, y, radius = 0, depth = 1, mode = 'query', unproject3D, onViewportActive, effects }: PickByPointOptions & PickOperationContext): { result: PickingInfo[]; emptyInfo: PickingInfo; } { // @ts-expect-error TODO - assuming WebGL context const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); const pickableLayers = this._getPickable(layers); if (!pickableLayers || viewports.length === 0) { return { result: [], emptyInfo: getEmptyPickingInfo({viewports, x, y, pixelRatio}) }; } this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates // Top-left coordinates [x, y] to bottom-left coordinates [deviceX, deviceY] // And compensate for pixelRatio // @ts-expect-error TODO - assuming WebGL context const devicePixelRange = this.device.canvasContext.cssToDevicePixels([x, y], true); const devicePixel = [ devicePixelRange.x + Math.floor(devicePixelRange.width / 2), devicePixelRange.y + Math.floor(devicePixelRange.height / 2) ]; const deviceRadius = Math.round(radius * pixelRatio); const {width, height} = this.pickingFBO as Framebuffer; const deviceRect = this._getPickingRect({ deviceX: devicePixel[0], deviceY: devicePixel[1], deviceRadius, deviceWidth: width, deviceHeight: height }); const cullRect: Rect = { x: x - radius, y: y - radius, width: radius * 2 + 1, height: radius * 2 + 1 }; let infos: Map<string | null, PickingInfo>; const result: PickingInfo[] = []; const affectedLayers = new Set<Layer>(); for (let i = 0; i < depth; i++) { let pickInfo: PickedPixel; if (deviceRect) { const pickedResult = this._drawAndSample({ layers: pickableLayers, views, viewports, onViewportActive, deviceRect, cullRect, effects, pass: `picking:${mode}` }); pickInfo = getClosestObject({ ...pickedResult, deviceX: devicePixel[0], deviceY: devicePixel[1], deviceRadius, deviceRect }); } else { pickInfo = { pickedColor: null, pickedObjectIndex: -1 }; } let z; if (pickInfo.pickedLayer && unproject3D && this.depthFBO) { const {pickedColors: pickedColors2} = this._drawAndSample( { layers: [pickInfo.pickedLayer], views, viewports, onViewportActive, deviceRect: { x: pickInfo.pickedX as number, y: pickInfo.pickedY as number, width: 1, height: 1 }, cullRect, effects, pass: `picking:${mode}:z` }, true ); // picked value is in common space (pixels) from the camera target (viewport.position) // convert it to meters from the ground if (pickedColors2[3]) { z = pickedColors2[0]; } } // Only exclude if we need to run picking again. // We need to run picking again if an object is detected AND // we have not exhausted the requested depth. if (pickInfo.pickedLayer && i + 1 < depth) { affectedLayers.add(pickInfo.pickedLayer); pickInfo.pickedLayer.disablePickingIndex(pickInfo.pickedObjectIndex); } // This logic needs to run even if no object is picked. infos = processPickInfo({ pickInfo, lastPickedInfo: this.lastPickedInfo, mode, layers: pickableLayers, viewports, x, y, z, pixelRatio }); for (const info of infos.values()) { if (info.layer) { result.push(info); } } // If no object is picked stop. if (!pickInfo.pickedColor) { break; } } // reset only affected buffers for (const layer of affectedLayers) { layer.restorePickingColors(); } return {result, emptyInfo: infos!.get(null) as PickingInfo}; } /** Pick all objects within the given bounding box */ // eslint-disable-next-line max-statements _pickVisibleObjects({ layers, views, viewports, x, y, width = 1, height = 1, mode = 'query', maxObjects = null, onViewportActive, effects }: PickByRectOptions & PickOperationContext): PickingInfo[] { const pickableLayers = this._getPickable(layers); if (!pickableLayers || viewports.length === 0) { return []; } this._resizeBuffer(); // Convert from canvas top-left to WebGL bottom-left coordinates // And compensate for pixelRatio // @ts-expect-error TODO - assuming WebGL context const pixelRatio = this.device.canvasContext.cssToDeviceRatio(); // @ts-expect-error TODO - assuming WebGL context const leftTop = this.device.canvasContext.cssToDevicePixels([x, y], true); // take left and top (y inverted in device pixels) from start location const deviceLeft = leftTop.x; const deviceTop = leftTop.y + leftTop.height; // take right and bottom (y inverted in device pixels) from end location // @ts-expect-error TODO - assuming WebGL context const rightBottom = this.device.canvasContext.cssToDevicePixels([x + width, y + height], true); const deviceRight = rightBottom.x + rightBottom.width; const deviceBottom = rightBottom.y; const deviceRect = { x: deviceLeft, y: deviceBottom, // deviceTop and deviceRight represent the first pixel outside the desired rect width: deviceRight - deviceLeft, height: deviceTop - deviceBottom }; const pickedResult = this._drawAndSample({ layers: pickableLayers, views, viewports, onViewportActive, deviceRect, cullRect: {x, y, width, height}, effects, pass: `picking:${mode}` }); const pickInfos = getUniqueObjects(pickedResult); // `getUniqueObjects` dedup by picked color // However different picked color may be linked to the same picked object, e.g. stroke and fill of the same polygon // picked from different sub layers of a GeoJsonLayer // Here after resolving the picked index with `layer.getPickingInfo`, we need to dedup again by unique picked objects const uniquePickedObjects = new Map<string, Set<unknown>>(); const uniqueInfos: PickingInfo[] = []; const limitMaxObjects = Number.isFinite(maxObjects); for (let i = 0; i < pickInfos.length; i++) { if (limitMaxObjects && uniqueInfos.length >= maxObjects!) { break; } const pickInfo = pickInfos[i]; let info: PickingInfo = { color: pickInfo.pickedColor, layer: null, index: pickInfo.pickedObjectIndex, picked: true, x, y, pixelRatio }; info = getLayerPickingInfo({layer: pickInfo.pickedLayer as Layer, info, mode}); // info.layer is always populated because it's a picked pixel const pickedLayerId = info.layer!.id; if (!uniquePickedObjects.has(pickedLayerId)) { uniquePickedObjects.set(pickedLayerId, new Set<unknown>()); } const uniqueObjectsInLayer = uniquePickedObjects.get(pickedLayerId) as Set<unknown>; // info.object may be null if the layer is using non-iterable data. // Fall back to using index as identifier. const pickedObjectKey = info.object ?? info.index; if (!uniqueObjectsInLayer.has(pickedObjectKey)) { uniqueObjectsInLayer.add(pickedObjectKey); uniqueInfos.push(info); } } return uniqueInfos; } /** Renders layers into the picking buffer with picking colors and read the pixels. */ _drawAndSample(params: { deviceRect: Rect; pass: string; layers: Layer[]; views: Record<string, View>; viewports: Viewport[]; onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; }): { pickedColors: Uint8Array; decodePickingColor: PickingColorDecoder; }; /** Renders layers into the picking buffer with encoded z values and read the pixels. */ _drawAndSample( params: { deviceRect: Rect; pass: string; layers: Layer[]; views: Record<string, View>; viewports: Viewport[]; onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; }, pickZ: true ): { pickedColors: Float32Array; decodePickingColor: null; }; _drawAndSample( { layers, views, viewports, onViewportActive, deviceRect, cullRect, effects, pass }: { deviceRect: Rect; pass: string; layers: Layer[]; views: Record<string, View>; viewports: Viewport[]; onViewportActive: (viewport: Viewport) => void; cullRect?: Rect; effects: Effect[]; }, pickZ: boolean = false ): { pickedColors: Uint8Array | Float32Array; decodePickingColor: PickingColorDecoder | null; } { const pickingFBO = pickZ ? this.depthFBO : this.pickingFBO; const opts = { layers, layerFilter: this.layerFilter, views, viewports, onViewportActive, pickingFBO, deviceRect, cullRect, effects, pass, pickZ, preRenderStats: {}, isPicking: true }; for (const effect of effects) { if (effect.useInPicking) { opts.preRenderStats[effect.id] = effect.preRender(opts); } } const {decodePickingColor} = this.pickLayersPass.render(opts); // Read from an already rendered picking buffer // Returns an Uint8ClampedArray of picked pixels const {x, y, width, height} = deviceRect; const pickedColors = new (pickZ ? Float32Array : Uint8Array)(width * height * 4); this.device.readPixelsToArrayWebGL(pickingFBO as Framebuffer, { sourceX: x, sourceY: y, sourceWidth: width, sourceHeight: height, target: pickedColors }); return {pickedColors, decodePickingColor}; } // Calculate a picking rect centered on deviceX and deviceY and clipped to device // Returns null if pixel is outside of device _getPickingRect({ deviceX, deviceY, deviceRadius, deviceWidth, deviceHeight }: { deviceX: number; deviceY: number; deviceRadius: number; deviceWidth: number; deviceHeight: number; }): Rect | null { // Create a box of size `radius * 2 + 1` centered at [deviceX, deviceY] const x = Math.max(0, deviceX - deviceRadius); const y = Math.max(0, deviceY - deviceRadius); const width = Math.min(deviceWidth, deviceX + deviceRadius + 1) - x; const height = Math.min(deviceHeight, deviceY + deviceRadius + 1) - y; // x, y out of bounds. if (width <= 0 || height <= 0) { return null; } return {x, y, width, height}; } }