@deck.gl/extensions
Version:
Plug-and-play functionalities for deck.gl layers
406 lines (372 loc) • 14.1 kB
text/typescript
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import type {Buffer, Framebuffer} from '@luma.gl/core';
import type {Model} from '@luma.gl/engine';
import type {Layer, LayerContext, Accessor, UpdateParameters} from '@deck.gl/core';
import {_deepEqual as deepEqual, LayerExtension, log} from '@deck.gl/core';
import {
CategoryBitMask,
DataFilterModuleProps,
Defines,
dataFilter,
dataFilter64
} from './shader-module';
import * as aggregator from './aggregator';
import {NumberArray4} from '@math.gl/core';
const defaultProps = {
getFilterValue: {type: 'accessor', value: 0},
getFilterCategory: {type: 'accessor', value: 0},
onFilteredItemsChange: {type: 'function', value: null, optional: true},
filterEnabled: true,
filterRange: [-1, 1],
filterSoftRange: null,
filterCategories: [0],
filterTransformSize: true,
filterTransformColor: true
};
type FilterCategory = number | string;
export type DataFilterExtensionProps<DataT = any> = {
/**
* Accessor to retrieve the value for each object that it will be filtered by.
* Returns either a number (if `filterSize: 1`) or an array of numbers.
*/
getFilterValue?: Accessor<DataT, number | number[]>;
/**
* Accessor to retrieve the category (`number | string`) for each object that it will be filtered by.
* Returns either a single category (if `filterSize: 1`) or an array of categories.
*/
getFilterCategory?: Accessor<DataT, FilterCategory | FilterCategory[]>;
/**
* Enable/disable the data filter. If the data filter is disabled, all objects are rendered.
* @default true
*/
filterEnabled?: boolean;
/**
* The [min, max] bounds which defines whether an object should be rendered.
* If an object's filtered value is within the bounds, the object will be rendered; otherwise it will be hidden.
* @default [-1, 1]
*/
filterRange?: Readonly<[number, number]> | Readonly<[number, number]>[];
/**
* If specified, objects will be faded in/out instead of abruptly shown/hidden.
* When the filtered value is outside of the bounds defined by `filterSoftRange` but still within the bounds defined by `filterRange`, the object will be rendered as "faded."
* @default null
*/
filterSoftRange?: Readonly<[number, number]> | Readonly<[number, number]>[] | null;
/**
* When an object is "faded", manipulate its size so that it appears smaller or thinner. Only works if `filterSoftRange` is specified.
* @default true
*/
filterTransformSize?: boolean;
/**
* When an object is "faded", manipulate its opacity so that it appears more translucent. Only works if `filterSoftRange` is specified.
* @default true
*/
filterTransformColor?: boolean;
/**
* The categories which define whether an object should be rendered.
* @default []
*/
filterCategories?: FilterCategory[] | FilterCategory[][];
/**
* Only called if the `countItems` option is enabled.
*/
onFilteredItemsChange?: (evt: {
/** The id of the source layer. */
id: string;
/** The number of data objects that pass the filter. */
count: number;
}) => void;
};
export type DataFilterExtensionOptions = {
/**
* The size of the category filter (number of columns to filter by). The category filter can show/hide data based on 1-4 properties of each object. Set to `0` to disable category filtering.
* @default 0
*/
categorySize?: 0 | 1 | 2 | 3 | 4;
/**
* The size of the filter (number of columns to filter by). The data filter can show/hide data based on 1-4 numeric properties of each object. Set to `0` to disable numeric filtering.
* @default 1
*/
filterSize?: 0 | 1 | 2 | 3 | 4;
/**
* Use 64-bit precision instead of 32-bit.
* @default false
*/
fp64?: boolean;
/**
* If `true`, reports the number of filtered objects with the `onFilteredItemsChange` callback.
* @default `false`.
*/
countItems?: boolean;
};
const defaultOptions: Required<DataFilterExtensionOptions> = {
categorySize: 0,
filterSize: 1,
fp64: false,
countItems: false
};
const CATEGORY_TYPE_FROM_SIZE = {
1: 'uint' as const,
2: 'uvec2' as const,
3: 'uvec3' as const,
4: 'uvec4' as const
};
const DATA_TYPE_FROM_SIZE = {
1: 'float' as const,
2: 'vec2' as const,
3: 'vec3' as const,
4: 'vec4' as const
};
/** Adds GPU-based data filtering functionalities to layers. It allows the layer to show/hide objects based on user-defined properties. */
export default class DataFilterExtension extends LayerExtension<
Required<DataFilterExtensionOptions>
> {
static defaultProps = defaultProps;
static extensionName = 'DataFilterExtension';
constructor(opts: DataFilterExtensionOptions = {}) {
super({...defaultOptions, ...opts});
}
getShaders(this: Layer<DataFilterExtensionProps>, extension: this): any {
const {categorySize, filterSize, fp64} = extension.opts;
const defines: Defines = {};
if (categorySize) {
defines.DATACATEGORY_TYPE = CATEGORY_TYPE_FROM_SIZE[categorySize];
defines.DATACATEGORY_CHANNELS = categorySize;
}
if (filterSize) {
defines.DATAFILTER_TYPE = DATA_TYPE_FROM_SIZE[filterSize];
defines.DATAFILTER_DOUBLE = Boolean(fp64);
}
const module = fp64 ? dataFilter64 : dataFilter;
module.uniformTypes = module.uniformTypesFromOptions(extension.opts);
return {modules: [module], defines};
}
initializeState(this: Layer<DataFilterExtensionProps>, context: LayerContext, extension: this) {
const attributeManager = this.getAttributeManager();
const {categorySize, filterSize, fp64} = extension.opts;
if (attributeManager) {
if (filterSize) {
attributeManager.add({
filterValues: {
size: filterSize,
type: fp64 ? 'float64' : 'float32',
stepMode: 'dynamic',
accessor: 'getFilterValue'
}
});
}
if (categorySize) {
attributeManager.add({
filterCategoryValues: {
size: categorySize,
stepMode: 'dynamic',
accessor: 'getFilterCategory',
type: 'uint32',
transform:
categorySize === 1
? d => extension._getCategoryKey.call(this, d, 0)
: d => d.map((x, i) => extension._getCategoryKey.call(this, x, i))
}
});
}
}
const {device} = this.context;
if (attributeManager && extension.opts.countItems) {
const useFloatTarget = aggregator.supportsFloatTarget(device);
// This attribute is needed for variable-width data, e.g. Path, SolidPolygon, Text
// The vertex shader checks if a vertex has the same "index" as the previous vertex
// so that we only write one count cross multiple vertices of the same object
attributeManager.add({
filterVertexIndices: {
size: useFloatTarget ? 1 : 2,
vertexOffset: 1,
type: 'unorm8',
accessor: (object, {index}) => {
const i = object && object.__source ? object.__source.index : index;
return useFloatTarget ? (i + 1) % 255 : [(i + 1) % 255, Math.floor(i / 255) % 255];
},
shaderAttributes: {
filterPrevIndices: {
vertexOffset: 0
},
filterIndices: {
vertexOffset: 1
}
}
}
});
const filterFBO = aggregator.getFramebuffer(device, useFloatTarget);
const filterModel = aggregator.getModel(
device,
attributeManager.getBufferLayouts({isInstanced: false}),
extension.getShaders.call(this, extension),
useFloatTarget
);
this.setState({filterFBO, filterModel});
}
}
// eslint-disable-next-line complexity
updateState(
this: Layer<DataFilterExtensionProps>,
{props, oldProps, changeFlags}: UpdateParameters<Layer<DataFilterExtensionProps>>,
extension: this
) {
const attributeManager = this.getAttributeManager();
const {categorySize} = extension.opts;
if (this.state.filterModel) {
const filterNeedsUpdate =
// attributeManager must be defined for filterModel to be set
attributeManager!.attributes.filterValues?.needsUpdate() ||
attributeManager!.attributes.filterCategoryValues?.needsUpdate() ||
props.filterEnabled !== oldProps.filterEnabled ||
props.filterRange !== oldProps.filterRange ||
props.filterSoftRange !== oldProps.filterSoftRange ||
props.filterCategories !== oldProps.filterCategories;
if (filterNeedsUpdate) {
this.setState({filterNeedsUpdate});
}
}
if (attributeManager?.attributes.filterCategoryValues) {
// Update bitmask if accessor or selected categories has changed
const categoryBitMaskNeedsUpdate =
attributeManager.attributes.filterCategoryValues.needsUpdate() ||
!deepEqual(props.filterCategories, oldProps.filterCategories, 2);
if (categoryBitMaskNeedsUpdate) {
this.setState({categoryBitMask: null});
}
// Need to recreate category map if categorySize has changed
const resetCategories = changeFlags.dataChanged;
if (resetCategories) {
this.setState({
categoryMap: Array(categorySize)
.fill(0)
.map(() => ({}))
});
attributeManager.attributes.filterCategoryValues.setNeedsUpdate('categoryMap');
}
}
}
// eslint-disable-next-line max-statements
draw(this: Layer<DataFilterExtensionProps>, params: any, extension: this) {
const filterFBO = this.state.filterFBO as Framebuffer;
const filterModel = this.state.filterModel as Model;
const filterNeedsUpdate = this.state.filterNeedsUpdate as boolean;
if (!this.state.categoryBitMask) {
extension._updateCategoryBitMask.call(this, params, extension);
}
const {
onFilteredItemsChange,
extensions,
filterEnabled,
filterRange,
filterSoftRange,
filterTransformSize,
filterTransformColor,
filterCategories
} = this.props;
const dataFilterProps: DataFilterModuleProps = {
extensions,
filterEnabled,
filterRange,
filterSoftRange,
filterTransformSize,
filterTransformColor,
filterCategories
};
if (this.state.categoryBitMask) {
dataFilterProps.categoryBitMask = this.state.categoryBitMask as CategoryBitMask;
}
this.setShaderModuleProps({dataFilter: dataFilterProps});
/* eslint-disable-next-line camelcase */
if (filterNeedsUpdate && onFilteredItemsChange && filterModel) {
const attributeManager = this.getAttributeManager()!;
const {
attributes: {filterValues, filterCategoryValues, filterVertexIndices}
} = attributeManager;
filterModel.setVertexCount(this.getNumInstances());
// @ts-expect-error filterValue and filterVertexIndices should always have buffer value
const attributes: Record<string, Buffer> = {
...filterValues?.getValue(),
...filterCategoryValues?.getValue(),
...filterVertexIndices?.getValue()
};
filterModel.setAttributes(attributes);
filterModel.shaderInputs.setProps({
dataFilter: dataFilterProps
});
const viewport = [0, 0, filterFBO.width, filterFBO.height] as NumberArray4;
const renderPass = filterModel.device.beginRenderPass({
id: 'data-filter-aggregation',
framebuffer: filterFBO,
parameters: {viewport},
clearColor: [0, 0, 0, 0]
});
filterModel.setParameters(aggregator.parameters);
filterModel.draw(renderPass);
renderPass.end();
const color = filterModel.device.readPixelsToArrayWebGL(filterFBO);
let count = 0;
for (let i = 0; i < color.length; i++) {
count += color[i];
}
onFilteredItemsChange({id: this.id, count});
this.state.filterNeedsUpdate = false;
}
}
finalizeState(this: Layer<DataFilterExtensionProps>) {
const filterFBO = this.state.filterFBO as Framebuffer;
const filterModel = this.state.filterModel as Model;
// filterFBO.color.delete();
filterFBO?.destroy();
filterModel?.destroy();
}
/**
* Updates the bitmask used on the GPU to perform the filter based on the
* `filterCategories` prop. The mapping between categories and bit in the bitmask
* is performed by `_getCategoryKey()`
*/
_updateCategoryBitMask(
this: Layer<DataFilterExtensionProps>,
params: any,
extension: this
): void {
const {categorySize} = extension.opts;
if (!categorySize) return;
const {filterCategories} = this.props;
const categoryBitMask: CategoryBitMask = new Uint32Array([0, 0, 0, 0]);
const categoryFilters = (
categorySize === 1 ? [filterCategories] : filterCategories
) as FilterCategory[][];
const maxCategories = categorySize === 1 ? 128 : categorySize === 2 ? 64 : 32;
for (let c = 0; c < categoryFilters.length; c++) {
const categoryFilter = categoryFilters[c];
for (const category of categoryFilter) {
const key = extension._getCategoryKey.call(this, category, c);
if (key < maxCategories) {
const channel = c * (maxCategories / 32) + Math.floor(key / 32);
categoryBitMask[channel] += Math.pow(2, key % 32); // 1 << key fails for key > 30
} else {
log.warn(`Exceeded maximum number of categories (${maxCategories})`)();
}
}
}
this.state.categoryBitMask = categoryBitMask;
}
/**
* Returns an index of bit in the bitmask for a given category. If the category has
* not yet been assigned a bit, a new one is assigned.
*/
_getCategoryKey(
this: Layer<DataFilterExtensionProps>,
category: FilterCategory,
channel: number
) {
const categoryMap = (this.state.categoryMap as Record<FilterCategory, number>[])[channel];
if (!(category in categoryMap)) {
categoryMap[category] = Object.keys(categoryMap).length;
}
return categoryMap[category];
}
}