diff --git a/modules/core/package.json b/modules/core/package.json index 64d64bb7d56..6ee3af2df34 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -47,6 +47,7 @@ "@luma.gl/engine": "^9.2.4", "@luma.gl/shadertools": "^9.2.4", "@luma.gl/webgl": "^9.2.4", + "@math.gl/culling": "^4.1.0", "@math.gl/core": "^4.1.0", "@math.gl/sun": "^4.1.0", "@math.gl/types": "^4.1.0", diff --git a/modules/core/src/index.ts b/modules/core/src/index.ts index ef4463aab19..5760c73eb01 100644 --- a/modules/core/src/index.ts +++ b/modules/core/src/index.ts @@ -35,6 +35,7 @@ export {default as AttributeManager} from './lib/attribute/attribute-manager'; export {default as Layer} from './lib/layer'; export {default as CompositeLayer} from './lib/composite-layer'; export {default as DeckRenderer} from './lib/deck-renderer'; +export {transformBoundsToWorld} from './lib/world-bounds'; // Viewports export {default as Viewport} from './viewports/viewport'; diff --git a/modules/core/src/lib/layer.ts b/modules/core/src/lib/layer.ts index 022cc0efb90..bade0959849 100644 --- a/modules/core/src/lib/layer.ts +++ b/modules/core/src/lib/layer.ts @@ -18,11 +18,13 @@ import memoize from '../utils/memoize'; import {mergeShaders} from '../utils/shader'; import {projectPosition, getWorldPosition} from '../shaderlib/project/project-functions'; import typedArrayManager from '../utils/typed-array-manager'; +import {deepEqual} from '../utils/deep-equal'; import Component from '../lifecycle/component'; import LayerState, {ChangeFlags} from './layer-state'; import {worldToPixels} from '@math.gl/web-mercator'; +import {OrientedBoundingBox} from '@math.gl/culling'; import {load} from '@loaders.gl/core'; @@ -39,6 +41,7 @@ import type {LayerContext} from './layer-manager'; import type {BinaryAttribute} from './attribute/attribute'; import {RenderPass} from '@luma.gl/core'; import {PickingProps} from '@luma.gl/shadertools'; +import {transformBoundsToWorld, WorldBoundsTransformOptions} from './world-bounds'; const TRACE_CHANGE_FLAG = 'layer.changeFlag'; const TRACE_INITIALIZE = 'layer.initialize'; @@ -201,6 +204,13 @@ export default abstract class Layer extends Component< state!: SharedLayerState; // Will be set to the shared layer state object during layer matching parent: Layer | null = null; + private _cachedLocalBounds: [number[], number[]] | null = null; + private _cachedWorldBounds: OrientedBoundingBox | null = null; + private _cachedWorldBoundsCacheKey: unknown; + private _cachedWorldBoundsProps: Omit | null = + null; + private _worldBoundsViewportId?: string; + private _worldBoundsDirty: boolean = true; get root(): Layer { // eslint-disable-next-line @@ -446,6 +456,67 @@ export default abstract class Layer extends Component< return this.getAttributeManager()?.getBounds(['positions', 'instancePositions']); } + // Default implementation + getWorldBounds(): OrientedBoundingBox | null { + const options = this.getWorldBoundsOptions(); + if (!options) { + return null; + } + + const {bounds, viewport, worldBoundsCacheKey, ...transformOptions} = options; + const cacheKey = worldBoundsCacheKey ?? null; + const viewportId = viewport?.id; + const useCache = + !this._worldBoundsDirty && + this._cachedWorldBounds && + deepEqual(bounds, this._cachedLocalBounds, 3) && + deepEqual(transformOptions, this._cachedWorldBoundsProps, 3) && + deepEqual(cacheKey, this._cachedWorldBoundsCacheKey, 3) && + viewportId === this._worldBoundsViewportId; + + if (useCache) { + return this._cachedWorldBounds; + } + + const worldBounds = transformBoundsToWorld({ + bounds, + viewport, + ...transformOptions + }); + + this._cachedWorldBounds = worldBounds; + this._cachedLocalBounds = bounds; + this._cachedWorldBoundsProps = transformOptions; + this._cachedWorldBoundsCacheKey = cacheKey; + this._worldBoundsViewportId = viewportId; + this._worldBoundsDirty = false; + + return worldBounds; + } + + /** + * Override to customize how world bounds are generated and cached. + * Add `worldBoundsCacheKey` to the returned object if additional props affect bounds, + * e.g. radius or scale. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getWorldBoundsOptions(): WorldBoundsTransformOptions | null { + const bounds = this.getBounds(); + const viewport = this.internalState?.viewport || this.context.viewport; + if (!bounds || !viewport) { + return null; + } + + const {coordinateOrigin, coordinateSystem, modelMatrix} = this.props; + return { + bounds, + viewport, + coordinateOrigin, + coordinateSystem, + modelMatrix + }; + } + // / LIFECYCLE METHODS - overridden by the layer subclasses /** Called once to set up the initial state. Layers can create WebGL resources here. */ @@ -472,6 +543,9 @@ export default abstract class Layer extends Component< updateState(params: UpdateParameters>): void { const attributeManager = this.getAttributeManager(); const {dataChanged} = params.changeFlags; + if (params.changeFlags.propsOrDataChanged || params.changeFlags.viewportChanged) { + this._worldBoundsDirty = true; + } if (dataChanged && attributeManager) { if (Array.isArray(dataChanged)) { // is partial update diff --git a/modules/core/src/lib/world-bounds.ts b/modules/core/src/lib/world-bounds.ts new file mode 100644 index 00000000000..11ff43f5c92 --- /dev/null +++ b/modules/core/src/lib/world-bounds.ts @@ -0,0 +1,54 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {projectPosition} from '../shaderlib/project/project-functions'; +import {makeOrientedBoundingBoxFromPoints, OrientedBoundingBox} from '@math.gl/culling'; + +import type Viewport from '../viewports/viewport'; +import type {CoordinateSystem} from './constants'; +import type {NumericArray} from '../types/types'; + +export type WorldBoundsTransformOptions = { + bounds: [number[], number[]]; + viewport: Viewport; + coordinateSystem: CoordinateSystem; + coordinateOrigin: [number, number, number]; + modelMatrix?: NumericArray | null; + /** Additional props that affect bounds transformation and caching */ + worldBoundsCacheKey?: unknown; +}; + +export function transformBoundsToWorld(options: WorldBoundsTransformOptions): OrientedBoundingBox { + const {bounds, viewport, coordinateOrigin, coordinateSystem, modelMatrix} = options; + const corners = getBoundingBoxCorners(bounds); + + const worldPositions = corners.map(position => + projectPosition(position, { + viewport, + coordinateSystem, + coordinateOrigin, + modelMatrix + }) + ); + + return makeOrientedBoundingBoxFromPoints(worldPositions); +} + +function getBoundingBoxCorners(bounds: [number[], number[]]): number[][] { + const [min, max] = bounds; + const dimension = Math.max(min.length, max.length, 3); + const corners: number[][] = []; + + for (let i = 0; i < 8; i++) { + const corner: number[] = []; + for (let axis = 0; axis < 3; axis++) { + const minValue = axis < dimension ? (min[axis] ?? 0) : 0; + const maxValue = axis < dimension ? (max[axis] ?? minValue) : minValue; + corner[axis] = i & (1 << axis) ? maxValue : minValue; + } + corners.push(corner); + } + + return corners; +} diff --git a/modules/main/src/index.ts b/modules/main/src/index.ts index 04e8fe76983..adb13e65e55 100644 --- a/modules/main/src/index.ts +++ b/modules/main/src/index.ts @@ -76,6 +76,7 @@ export { fp64LowPart, createIterable, getShaderAssembler, + transformBoundsToWorld, // Widgets Widget } from '@deck.gl/core'; diff --git a/test/modules/core/lib/index.ts b/test/modules/core/lib/index.ts index 8622e91e25f..fe2d4590cea 100644 --- a/test/modules/core/lib/index.ts +++ b/test/modules/core/lib/index.ts @@ -18,3 +18,4 @@ import './pick-layers.spec'; import './picking.spec'; import './view-manager.spec'; import './widget-manager.spec'; +import './world-bounds.spec'; diff --git a/test/modules/core/lib/world-bounds.spec.ts b/test/modules/core/lib/world-bounds.spec.ts new file mode 100644 index 00000000000..56907e8b3ab --- /dev/null +++ b/test/modules/core/lib/world-bounds.spec.ts @@ -0,0 +1,106 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from 'tape-catch'; +import {Layer, COORDINATE_SYSTEM, transformBoundsToWorld, Viewport} from '@deck.gl/core'; +import {Matrix4} from '@math.gl/core'; +import LayerState from '../../../../modules/core/src/lib/layer-state'; + +class WorldBoundsLayer extends Layer<{customKey?: number}> { + initializeState() {} + + override getBounds() { + return [ + [-1, 0, 0], + [1, 0, 0] + ]; + } + + protected override getWorldBoundsOptions() { + const options = super.getWorldBoundsOptions(); + if (!options) { + return null; + } + return {...options, worldBoundsCacheKey: this.props.customKey}; + } +} + +WorldBoundsLayer.defaultProps = { + customKey: 0 +}; + +const VIEWPORT = new Viewport({ + id: 'identity', + width: 1, + height: 1, + position: [0, 0, 0], + viewMatrix: new Matrix4().identity(), + projectionMatrix: new Matrix4().identity() +}); + +test('transformBoundsToWorld#modelMatrix applied to corners', t => { + const modelMatrix = new Matrix4().rotateZ(Math.PI / 2); + const bounds = [ + [-1, 0, 0], + [1, 0, 0] + ] as [number[], number[]]; + + const obb = transformBoundsToWorld({ + bounds, + coordinateOrigin: [0, 0, 0], + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + modelMatrix, + viewport: VIEWPORT + }); + + const halfAxes = obb.halfAxes; + t.ok(Math.abs(halfAxes[0]) < 1e-6, 'x component is rotated to zero'); + t.ok(Math.abs(halfAxes[1] - 1) < 1e-6, 'y component reflects rotation'); + t.deepEqual([...obb.center], [0, 0, 0], 'center is preserved'); + t.end(); +}); + +test('Layer#getWorldBounds#cache invalidation and key', t => { + const layer = new WorldBoundsLayer({ + id: 'world-bounds-layer', + data: [], + coordinateSystem: COORDINATE_SYSTEM.CARTESIAN, + customKey: 0 + }); + + layer.context = {viewport: VIEWPORT} as any; + layer.internalState = new LayerState({attributeManager: null, layer}); + layer.internalState.viewport = VIEWPORT; + + const first = layer.getWorldBounds(); + const second = layer.getWorldBounds(); + t.equal(first, second, 'bounds are cached when inputs are unchanged'); + + layer.updateState({ + props: layer.props, + oldProps: layer.props, + context: layer.context, + changeFlags: { + dataChanged: false, + propsChanged: false, + updateTriggersChanged: false, + extensionsChanged: false, + viewportChanged: true, + stateChanged: false, + propsOrDataChanged: false, + somethingChanged: true + } + }); + + const afterViewportChange = layer.getWorldBounds(); + t.notEqual(afterViewportChange, first, 'cache invalidates when viewport changes'); + + const cachedAgain = layer.getWorldBounds(); + t.equal(afterViewportChange, cachedAgain, 'bounds cache is reused after recompute'); + + layer.props = {...layer.props, customKey: 1}; + const afterKeyChange = layer.getWorldBounds(); + t.notEqual(afterKeyChange, cachedAgain, 'cache key forces recomputation'); + t.end(); +});