diff --git a/modules/aggregation-layers/src/aggregation-layer-v9/aggregator.ts b/modules/aggregation-layers/src/aggregation-layer-v9/aggregator.ts index c76ddc9e65c..5f432d8dc89 100644 --- a/modules/aggregation-layers/src/aggregation-layer-v9/aggregator.ts +++ b/modules/aggregation-layers/src/aggregation-layer-v9/aggregator.ts @@ -63,7 +63,7 @@ export interface Aggregator { destroy(): void; /** Get the number of bins */ - get numBins(): number; + get binCount(): number; /** Returns an accessor to the bins. */ getBins(): BinaryAttribute | null; diff --git a/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/utils.ts b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/utils.ts new file mode 100644 index 00000000000..51d34d7cb48 --- /dev/null +++ b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/utils.ts @@ -0,0 +1,29 @@ +import type {Device, Framebuffer} from '@luma.gl/core'; + +/** + * Marks GLSL shaders for syntax highlighting: glsl`...` + * Install https://marketplace.visualstudio.com/items?itemName=boyswan.glsl-literal + */ +export const glsl = (s: TemplateStringsArray) => `${s}`; + +/** + * Create a float texture to store aggregation result + */ +export function createRenderTarget(device: Device, width: number, height: number): Framebuffer { + return device.createFramebuffer({ + width, + height, + colorAttachments: [ + device.createTexture({ + width, + height, + format: 'rgba32float', + mipmaps: false, + sampler: { + minFilter: 'nearest', + magFilter: 'nearest' + } + }) + ] + }); +} diff --git a/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-aggregation-transform.ts b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-aggregation-transform.ts new file mode 100644 index 00000000000..742a18737be --- /dev/null +++ b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-aggregation-transform.ts @@ -0,0 +1,243 @@ +import {BufferTransform} from '@luma.gl/engine'; +import {glsl, createRenderTarget} from './utils'; + +import type {Device, Framebuffer, Buffer, Texture} from '@luma.gl/core'; +import type {WebGLAggregatorOptions} from './webgl-aggregator'; +import type {AggregationOperation} from '../aggregator'; + +import {TEXTURE_WIDTH} from './webgl-bin-sorter'; + +const MAX_FLOAT32 = 3e38; + +export class WebGLAggregationTransform { + device: Device; + channelCount: number; + + /** Packed from bin ids */ + binBuffer: Buffer | null = null; + /** Packed values from each channel of each bin + * Stride is number of channels * 4 bytes (float32) + */ + valueBuffer: Buffer | null = null; + + private transform: BufferTransform; + /** Render target for calculating domain */ + private domainFBO: Framebuffer; + /** Aggregated [min, max] for each channel */ + private _domains: [min: number, max: number][] | null = null; + + constructor(device: Device, settings: WebGLAggregatorOptions) { + this.device = device; + this.channelCount = settings.channelCount; + this.transform = createTransform(device, settings); + this.domainFBO = createRenderTarget(device, 2, 1); + } + + destroy() { + this.transform.destroy(); + this.binBuffer?.destroy(); + this.valueBuffer?.destroy(); + this.domainFBO.colorAttachments[0].texture.destroy(); + this.domainFBO.destroy(); + } + + get domains(): [min: number, max: number][] { + if (!this._domains) { + // Domain model has run, but result has not been read to CPU + const buffer = this.device.readPixelsToArrayWebGL(this.domainFBO).buffer; + const domain = new Float32Array(buffer); + this._domains = [ + [-domain[4], domain[0]], + [-domain[5], domain[1]], + [-domain[6], domain[2]] + ].slice(0, this.channelCount) as [number, number][]; + } + return this._domains; + } + + setDimensions(binCount: number, binIdRange: [number, number][]) { + const {model, transformFeedback} = this.transform; + model.setVertexCount(binCount); + model.setUniforms({ + binIdRange: [ + binIdRange[0][0], + binIdRange[0][1], + binIdRange[1]?.[0] || 0, + binIdRange[1]?.[1] || 0 + ] + }); + + // Only destroy existing buffer if it is not large enough + const binBufferByteLength = binCount * binIdRange.length * 4; + if (!this.binBuffer || this.binBuffer.byteLength < binBufferByteLength) { + this.binBuffer?.destroy(); + this.binBuffer = this.device.createBuffer({byteLength: binBufferByteLength}); + transformFeedback.setBuffer('binIds', this.binBuffer); + } + + const valueBufferByteLength = binCount * this.channelCount * 4; + if (!this.valueBuffer || this.valueBuffer.byteLength < valueBufferByteLength) { + this.valueBuffer?.destroy(); + this.valueBuffer = this.device.createBuffer({byteLength: valueBufferByteLength}); + transformFeedback.setBuffer('values', this.valueBuffer); + } + } + + update(bins: Texture | null, operations: AggregationOperation[]) { + if (!bins) { + return; + } + const transform = this.transform; + const target = this.domainFBO; + + transform.model.setUniforms({ + isCount: Array.from({length: 3}, (_, i) => (operations[i] === 'COUNT' ? 1 : 0)), + isMean: Array.from({length: 3}, (_, i) => (operations[i] === 'MEAN' ? 1 : 0)) + }); + transform.model.setBindings({bins}); + + transform.run({ + id: 'gpu-aggregation-domain', + framebuffer: target, + parameters: { + viewport: [0, 0, 2, 1] + }, + clearColor: [-MAX_FLOAT32, -MAX_FLOAT32, -MAX_FLOAT32, 0], + clearDepth: false, + clearStencil: false + }); + + // Clear the last read results. This will be lazy-populated if used. + this._domains = null; + } +} + +function createTransform(device: Device, settings: WebGLAggregatorOptions): BufferTransform { + const vs = glsl`\ +#version 300 es +#define SHADER_NAME gpu-aggregation-domain-vertex + +uniform ivec4 binIdRange; +uniform bvec3 isCount; +uniform bvec3 isMean; +uniform float naN; +uniform sampler2D bins; + +#if NUM_DIMS == 1 +out float binIds; +#else +out vec2 binIds; +#endif + +#if NUM_CHANNELS == 1 +out float values; +#elif NUM_CHANNELS == 2 +out vec2 values; +#else +out vec3 values; +#endif + +void main() { + int row = gl_VertexID / SAMPLER_WIDTH; + int col = gl_VertexID - row * SAMPLER_WIDTH; + vec4 weights = texelFetch(bins, ivec2(col, row), 0); + vec3 value3 = mix( + mix(weights.rgb, vec3(weights.a), isCount), + weights.rgb / max(weights.a, 1.0), + isMean + ); + if (weights.a == 0.0) { + value3 = vec3(naN); + } + +#if NUM_DIMS == 1 + binIds = float(gl_VertexID + binIdRange.x); +#else + int y = gl_VertexID / (binIdRange.y - binIdRange.x); + int x = gl_VertexID - y * (binIdRange.y - binIdRange.x); + binIds.y = float(y + binIdRange.z); + binIds.x = float(x + binIdRange.x); +#endif + +#if NUM_CHANNELS == 3 + values = value3; +#elif NUM_CHANNELS == 2 + values = value3.xy; +#else + values = value3.x; +#endif + + gl_Position = vec4(0., 0., 0., 1.); + // This model renders into a 2x1 texture to obtain min and max simultaneously. + // See comments in fragment shader + gl_PointSize = 2.0; +} +`; + + const fs = glsl`\ +#version 300 es +#define SHADER_NAME gpu-aggregation-domain-fragment + +precision highp float; + +#if NUM_CHANNELS == 1 +in float values; +#elif NUM_CHANNELS == 2 +in vec2 values; +#else +in vec3 values; +#endif + +out vec4 fragColor; + +void main() { +#if NUM_CHANNELS > 1 + if (isnan(values.x)) discard; +#else + if (isnan(values)) discard; +#endif + vec3 value3; +#if NUM_CHANNELS == 3 + value3 = values; +#elif NUM_CHANNELS == 2 + value3.xy = values; +#else + value3.x = values; +#endif + // This shader renders into a 2x1 texture with blending=max + // The left pixel yields the max value of each channel + // The right pixel yields the min value of each channel + if (gl_FragCoord.x < 1.0) { + fragColor = vec4(value3, 1.0); + } else { + fragColor = vec4(-value3, 1.0); + } +} +`; + + return new BufferTransform(device, { + vs, + fs, + topology: 'point-list', + parameters: { + blendColorSrcFactor: 'one', + blendColorDstFactor: 'one', + blendColorOperation: 'max', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one', + blendAlphaOperation: 'max' + }, + defines: { + NUM_DIMS: settings.dimensions, + NUM_CHANNELS: settings.channelCount, + SAMPLER_WIDTH: TEXTURE_WIDTH + }, + uniforms: { + // Passed in as uniform because 1) there is no GLSL symbol for NaN 2) any expression that exploits undefined behavior to produces NaN + // will subject to platform differences and shader optimization + naN: NaN + }, + varyings: ['binIds', 'values'], + disableWarnings: true + }); +} diff --git a/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-aggregator.ts b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-aggregator.ts new file mode 100644 index 00000000000..a16ff4fe694 --- /dev/null +++ b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-aggregator.ts @@ -0,0 +1,253 @@ +import {ModelProps} from '@luma.gl/engine'; +import {WebGLBinSorter} from './webgl-bin-sorter'; +import {WebGLAggregationTransform} from './webgl-aggregation-transform'; +import {_deepEqual as deepEqual, log, BinaryAttribute} from '@deck.gl/core'; + +import type {Aggregator, AggregationProps, AggregatedBin} from '../aggregator'; +import type {Device, Buffer, BufferLayout, TypedArray} from '@luma.gl/core'; +import type {ShaderModule} from '@luma.gl/shadertools'; + +/** Options used to construct a new WebGLAggregator */ +export type WebGLAggregatorOptions = { + /** Size of bin IDs */ + dimensions: 1 | 2; + /** How many properties to perform aggregation on */ + channelCount: 1 | 2 | 3; + /** Buffer layout for input attributes */ + bufferLayout?: BufferLayout[]; + /** Define a shader function with one of the signatures + * `void getBin(out int binId)`: if dimensions=1 + * `void getBin(out ivec2 binId)`: if dimensions=2 + * And a shader function with one of the signatures + * `void getValue(out float value)`: if channelCount=1 + * `void getValue(out vec2 value)`: if channelCount=2 + * `void getValue(out vec3 value)`: if channelCount=3 + */ + vs: string; + /** Shader modules + * Required to support certain layer extensions (e.g. data filter) + */ + modules?: ShaderModule[]; + /** Shadertool module defines */ + defines?: Record; +}; + +/** Props used to run GPU aggregation, can be changed at any time */ +export type WebGLAggregationProps = AggregationProps & { + /** Limits of binId defined for each dimension. Ids outside of the [start, end) are ignored. + */ + binIdRange: [start: number, end: number][]; + /** Context props passed to the shader modules */ + moduleSettings?: ModelProps['moduleSettings']; +}; + +/** An Aggregator implementation that calculates aggregation on the GPU */ +export class WebGLAggregator implements Aggregator { + /** Checks if the current device supports GPU aggregation */ + static isSupported(device: Device): boolean { + return ( + device.features.has('float32-renderable-webgl') && + device.features.has('texture-blend-float-webgl') + ); + } + + dimensions: 1 | 2; + channelCount: 1 | 2 | 3; + binCount: number = 0; + + device: Device; + props: WebGLAggregationProps = { + pointCount: 0, + binIdRange: [[0, 0]], + operations: [], + attributes: {}, + binOptions: {} + }; + + /** Dirty flag per channel */ + protected needsUpdate: boolean[]; + /** Step 1. sort data points into bins, blended using an aggregation opera†ion */ + protected binSorter: WebGLBinSorter; + /** Step 2. (optional) calculate the min/max across all bins */ + protected aggregationTransform: WebGLAggregationTransform; + + constructor(device: Device, settings: WebGLAggregatorOptions) { + this.device = device; + this.dimensions = settings.dimensions; + this.channelCount = settings.channelCount; + this.needsUpdate = new Array(this.channelCount).fill(true); + this.binSorter = new WebGLBinSorter(device, settings); + this.aggregationTransform = new WebGLAggregationTransform(device, settings); + } + + getBins(): BinaryAttribute | null { + const buffer = this.aggregationTransform.binBuffer; + if (!buffer) { + return null; + } + return {buffer, type: 'float32', size: this.dimensions}; + } + + /** Returns an accessor to the output for a given channel. */ + getResult(channel: 0 | 1 | 2): BinaryAttribute | null { + const buffer = this.aggregationTransform.valueBuffer; + if (!buffer || channel >= this.channelCount) { + return null; + } + return {buffer, type: 'float32', size: 1, stride: this.channelCount * 4, offset: channel * 4}; + } + + /** Returns the [min, max] of aggregated values for a given channel. */ + getResultDomain(channel: 0 | 1 | 2): [min: number, max: number] { + return this.aggregationTransform.domains[channel]; + } + + /** Returns the information for a given bin. */ + getBin(index: number): AggregatedBin | null { + if (index < 0 || index >= this.binCount) { + return null; + } + const {binIdRange} = this.props; + let id: number[]; + + if (this.dimensions === 1) { + id = [index + binIdRange[0][0]]; + } else { + const [[x0, x1], [y0]] = binIdRange; + const width = x1 - x0; + id = [(index % width) + x0, Math.floor(index / width) + y0]; + } + + const pixel = this.binSorter.getBinValues(index); + if (!pixel) { + return null; + } + const count = pixel[3]; + const value: number[] = []; + for (let channel = 0; channel < this.channelCount; channel++) { + const operation = this.props.operations[channel]; + if (operation === 'COUNT') { + value[channel] = count; + } else if (count === 0) { + value[channel] = NaN; + } else { + value[channel] = operation === 'MEAN' ? pixel[channel] / count : pixel[channel]; + } + } + return {id, value, count}; + } + + /** Release GPU resources */ + destroy() { + this.binSorter.destroy(); + this.aggregationTransform.destroy(); + } + + /** Update aggregation props. Normalize prop values and set change flags. */ + setProps(props: Partial) { + const oldProps = this.props; + + // Update local settings. These will set the flag this._needsUpdate + if ('binIdRange' in props && !deepEqual(props.binIdRange, oldProps.binIdRange, 2)) { + const binIdRange = props.binIdRange!; + log.assert(binIdRange.length === this.dimensions); + + if (this.dimensions === 1) { + const [[x0, x1]] = binIdRange; + this.binCount = x1 - x0; + } else { + const [[x0, x1], [y0, y1]] = binIdRange; + this.binCount = (x1 - x0) * (y1 - y0); + } + + this.binSorter.setDimensions(this.binCount, binIdRange); + this.aggregationTransform.setDimensions(this.binCount, binIdRange); + this.setNeedsUpdate(); + } + if (props.operations) { + for (let channel = 0; channel < this.channelCount; channel++) { + if (props.operations[channel] !== oldProps.operations[channel]) { + this.setNeedsUpdate(channel); + } + } + } + if (props.pointCount !== undefined && props.pointCount !== oldProps.pointCount) { + this.binSorter.setModelProps({vertexCount: props.pointCount}); + this.setNeedsUpdate(); + } + if (props.binOptions) { + if (!deepEqual(props.binOptions, oldProps.binOptions, 2)) { + this.setNeedsUpdate(); + } + this.binSorter.setModelProps({uniforms: props.binOptions}); + } + if (props.attributes) { + const attributeBuffers: Record = {}; + const constantAttributes: Record = {}; + + for (const attribute of Object.values(props.attributes)) { + for (const [attributeName, value] of Object.entries(attribute.getValue())) { + if (ArrayBuffer.isView(value)) { + constantAttributes[attributeName] = value; + } else if (value) { + attributeBuffers[attributeName] = value; + } + } + } + this.binSorter.setModelProps({attributes: attributeBuffers, constantAttributes}); + } + if (props.moduleSettings) { + this.binSorter.setModelProps({moduleSettings: props.moduleSettings}); + } + + Object.assign(this.props, props); + } + + /** Flags a channel to need update. + * This is called internally by setProps() if certain props change + * Users of this class still need to manually set the dirty flag sometimes, because even if no props changed + * the underlying buffers could have been updated and require rerunning the aggregation + * @param {number} channel - mark the given channel as dirty. If not provided, all channels will be updated. + */ + setNeedsUpdate(channel?: number) { + if (channel === undefined) { + this.needsUpdate.fill(true); + } else { + this.needsUpdate[channel] = true; + } + } + + update() {} + + /** Run aggregation */ + preDraw( + /** Parameters only available at runtime */ + opts: { + moduleSettings: any; + } + ) { + if (!this.needsUpdate.some(Boolean)) { + return; + } + + if (opts.moduleSettings) { + this.binSorter.setModelProps(opts); + } + + const {operations} = this.props; + const operationsToUpdate = this.needsUpdate.map((needsUpdate, i) => + needsUpdate ? operations[i] : null + ); + // Render data to bins + this.binSorter.update(operationsToUpdate); + // Read to buffer and calculate domain + this.aggregationTransform.update(this.binSorter.texture, operations); + + this.needsUpdate.fill(false); + + // Uncomment to debug + // console.log('binsFBO', new Float32Array(this.device.readPixelsToArrayWebGL(this.binSorter.texture!).buffer)); + // console.log('binsBuffer', new Float32Array(this.aggregationTransform.binBuffer?.readSyncWebGL().buffer!)); + // console.log('resultBuffer', new Float32Array(this.aggregationTransform.valueBuffer?.readSyncWebGL().buffer!)); + } +} diff --git a/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-bin-sorter.ts b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-bin-sorter.ts new file mode 100644 index 00000000000..e42c6113dc2 --- /dev/null +++ b/modules/aggregation-layers/src/aggregation-layer-v9/gpu-aggregator/webgl-bin-sorter.ts @@ -0,0 +1,259 @@ +import {Model, ModelProps} from '@luma.gl/engine'; +import {glsl, createRenderTarget} from './utils'; + +import type {Device, Framebuffer, Texture} from '@luma.gl/core'; +import type {WebGLAggregatorOptions} from './webgl-aggregator'; +import type {AggregationOperation} from '../aggregator'; + +const COLOR_CHANNELS = [0x1, 0x2, 0x4, 0x8]; // GPU color mask RED, GREEN, BLUE, ALPHA +const MAX_FLOAT32 = 3e38; +const EMPTY_MASKS = {SUM: 0, MEAN: 0, MIN: 0, MAX: 0, COUNT: 0}; + +export const TEXTURE_WIDTH = 1024; + +/** + * This class manages the resources for performing the first step of aggregation + * Sort a list of data points into a number of bins + */ +export class WebGLBinSorter { + device: Device; + model: Model; + + /** + * A packed texture in which each pixel represents a bin. + * The index of the pixel in the memory layout is the bin index. + * Alpha value is the count of data points that fall into this bin + * R,G,B values are the aggregated values of each channel: + * - Sum of all data points if operation is 'SUM', or 'MEAN' + * - Min of all data points if operation is 'MIN' + * - Max of all data points if operation is 'MAX' + */ + private binsFBO: Framebuffer | null = null; + + constructor(device: Device, settings: WebGLAggregatorOptions) { + this.device = device; + this.model = createModel(device, settings); + } + + get texture(): Texture | null { + return this.binsFBO ? this.binsFBO.colorAttachments[0].texture : null; + } + + destroy() { + this.model.destroy(); + this.binsFBO?.colorAttachments[0].texture.destroy(); + this.binsFBO?.destroy(); + } + + getBinValues(index: number): Float32Array | null { + if (!this.binsFBO) { + return null; + } + const x = index % TEXTURE_WIDTH; + const y = Math.floor(index / TEXTURE_WIDTH); + const buffer = this.device.readPixelsToArrayWebGL(this.binsFBO, { + sourceX: x, + sourceY: y, + sourceWidth: 1, + sourceHeight: 1 + }).buffer; + return new Float32Array(buffer); + } + + setDimensions(binCount: number, binIdRange: [number, number][]) { + const width = TEXTURE_WIDTH; + const height = Math.ceil(binCount / width); + + // Only destroy existing texture if it is not large enough + if (!this.binsFBO) { + this.binsFBO = createRenderTarget(this.device, width, height); + } else if (this.binsFBO.height < height) { + this.binsFBO.resize({width, height}); + } + + this.model.setUniforms({ + binIdRange: [ + binIdRange[0][0], + binIdRange[0][1], + binIdRange[1]?.[0] || 0, + binIdRange[1]?.[1] || 0 + ], + targetSize: [this.binsFBO.width, this.binsFBO.height] + }); + } + + setModelProps( + props: Pick< + ModelProps, + 'moduleSettings' | 'vertexCount' | 'uniforms' | 'attributes' | 'constantAttributes' + > + ) { + const model = this.model; + if (props.uniforms) { + model.setUniforms(props.uniforms); + } + if (props.attributes) { + model.setAttributes(props.attributes); + } + if (props.constantAttributes) { + model.setConstantAttributes(props.constantAttributes); + } + if (props.vertexCount !== undefined) { + model.setVertexCount(props.vertexCount); + } + if (props.moduleSettings) { + // TODO v9.1 - remove after migrate to UBO + model.updateModuleSettings(props.moduleSettings); + model.shaderInputs.setProps({project: props.moduleSettings}); + } + } + + /** Update aggregation */ + update( + /** The aggregation operation for each channel. Use null to skip update. */ + operations: (AggregationOperation | null)[] + ) { + if (!this.binsFBO) { + return; + } + const masks = getMaskByOperation(operations); + this._updateBins('SUM', masks.SUM + masks.MEAN); + this._updateBins('MIN', masks.MIN); + this._updateBins('MAX', masks.MAX); + } + + /** Recalculate aggregation on the given channels using the given operation */ + private _updateBins( + operation: AggregationOperation, + /** GPU bit mask of one or more channels that should be updated */ + colorMask: number + ) { + if (colorMask === 0) { + return; + } + colorMask |= COLOR_CHANNELS[3]; // Also renders to the alpha channel (point count) + + const model = this.model; + const target = this.binsFBO!; + + const initialValue = operation === 'MAX' ? -MAX_FLOAT32 : operation === 'MIN' ? MAX_FLOAT32 : 0; + const renderPass = this.device.beginRenderPass({ + id: `gpu-aggregation-${operation}`, + framebuffer: target, + parameters: { + viewport: [0, 0, target.width, target.height], + colorMask + }, + clearColor: [initialValue, initialValue, initialValue, 0], + clearDepth: false, + clearStencil: false + }); + model.setParameters({ + blendColorSrcFactor: 'one', + blendColorDstFactor: 'one', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one', + blendColorOperation: operation === 'MAX' ? 'max' : operation === 'MIN' ? 'min' : 'add', + blendAlphaOperation: 'add' + }); + model.draw(renderPass); + renderPass.end(); + } +} + +/** Convert a [channel -> operation] map to a [operation -> GPU color mask] map */ +function getMaskByOperation( + operations: (AggregationOperation | null)[] +): Record { + const result: Record = {...EMPTY_MASKS}; + for (let channel = 0; channel < operations.length; channel++) { + const op = operations[channel]; + if (op) { + result[op] += COLOR_CHANNELS[channel]; + } + } + return result; +} + +function createModel(device: Device, settings: WebGLAggregatorOptions): Model { + let userVs = settings.vs; + + if (settings.dimensions === 2) { + // If user provides 2d bin IDs, convert them to 1d indices for data packing + userVs += glsl` +void getBin(out int binId) { + ivec2 binId2; + getBin(binId2); + if (binId2.x < binIdRange.x || binId2.x >= binIdRange.y) { + binId = -1; + } else { + binId = (binId2.y - binIdRange.z) * (binIdRange.y - binIdRange.x) + binId2.x; + } +} +`; + } + + const vs = `\ +#version 300 es +#define SHADER_NAME gpu-aggregation-sort-bins-vertex + +uniform ivec4 binIdRange; +uniform ivec2 targetSize; + +${userVs} + +out vec3 v_Value; + +void main() { + int binIndex; + getBin(binIndex); + binIndex = binIndex - binIdRange.x; + if (binIndex < 0) { + gl_Position = vec4(0.); + return; + } + int row = binIndex / targetSize.x; + int col = binIndex - row * targetSize.x; + vec2 position = (vec2(col, row) + 0.5) / vec2(targetSize) * 2.0 - 1.0; + gl_Position = vec4(position, 0.0, 1.0); + gl_PointSize = 1.0; + +#if NUM_CHANNELS == 3 + getValue(v_Value); +#elif NUM_CHANNELS == 2 + getValue(v_Value.xy); +#else + getValue(v_Value.x); +#endif +} +`; + const fs = glsl`\ +#version 300 es +#define SHADER_NAME gpu-aggregation-sort-bins-fragment + +precision highp float; + +in vec3 v_Value; +out vec4 fragColor; + +void main() { + fragColor.xyz = v_Value; + + #ifdef MODULE_GEOMETRY + geometry.uv = vec2(0.); + DECKGL_FILTER_COLOR(fragColor, geometry); + #endif + + fragColor.w = 1.0; +} +`; + const model = new Model(device, { + ...settings, + defines: {...settings.defines, NON_INSTANCED_MODEL: 1, NUM_CHANNELS: settings.channelCount}, + isInstanced: false, + vs, + fs, + topology: 'point-list' + }); + return model; +} diff --git a/modules/aggregation-layers/src/index.ts b/modules/aggregation-layers/src/index.ts index a231fe12b7f..8cbd48ee334 100644 --- a/modules/aggregation-layers/src/index.ts +++ b/modules/aggregation-layers/src/index.ts @@ -33,6 +33,8 @@ export {default as _CPUAggregator} from './utils/cpu-aggregator'; export {default as _AggregationLayer} from './aggregation-layer'; export {default as _BinSorter} from './utils/bin-sorter'; +export {WebGLAggregator} from './aggregation-layer-v9/gpu-aggregator/webgl-aggregator'; + // types export type {ContourLayerProps} from './contour-layer/contour-layer'; export type {HeatmapLayerProps} from './heatmap-layer/heatmap-layer'; @@ -41,3 +43,8 @@ export type {CPUGridLayerProps} from './cpu-grid-layer/cpu-grid-layer'; export type {GridLayerProps} from './grid-layer/grid-layer'; export type {GPUGridLayerProps} from './gpu-grid-layer/gpu-grid-layer'; export type {ScreenGridLayerProps} from './screen-grid-layer/screen-grid-layer'; + +export type { + WebGLAggregationProps, + WebGLAggregatorOptions +} from './aggregation-layer-v9/gpu-aggregator/webgl-aggregator'; diff --git a/modules/main/src/index.ts b/modules/main/src/index.ts index d0b33509830..71d0af760e0 100644 --- a/modules/main/src/index.ts +++ b/modules/main/src/index.ts @@ -120,7 +120,8 @@ export { GridLayer, GPUGridLayer, AGGREGATION_OPERATION, - HeatmapLayer + HeatmapLayer, + WebGLAggregator } from '@deck.gl/aggregation-layers'; export { diff --git a/test/modules/aggregation-layers/aggregation-layer-v9/data-sample.ts b/test/modules/aggregation-layers/aggregation-layer-v9/data-sample.ts new file mode 100644 index 00000000000..a02041e1337 --- /dev/null +++ b/test/modules/aggregation-layers/aggregation-layer-v9/data-sample.ts @@ -0,0 +1,34 @@ +export const IncomeSurvey = [ + {age: 19, household: 1, income: 25, education: 1}, + {age: 20, household: 1, income: 0, education: 2}, + {age: 21, household: 1, income: 0, education: 2}, + {age: 22, household: 2, income: 40, education: 2}, + {age: 23, household: 1, income: 80, education: 3}, + {age: 24, household: 1, income: 120, education: 3}, + {age: 25, household: 1, income: 0, education: 4}, + {age: 26, household: 2, income: 60, education: 2}, + {age: 27, household: 1, income: 75, education: 3}, + {age: 28, household: 3, income: 20, education: 4}, + {age: 29, household: 4, income: 115, education: 3}, + {age: 30, household: 2, income: 90, education: 3}, + {age: 32, household: 1, income: 150, education: 4}, + {age: 34, household: 2, income: 60, education: 5}, + {age: 36, household: 3, income: 90, education: 3}, + {age: 38, household: 5, income: 200, education: 4}, + {age: 40, household: 4, income: 140, education: 4}, + {age: 42, household: 2, income: 110, education: 5}, + {age: 44, household: 4, income: 500, education: 4}, + {age: 46, household: 3, income: 80, education: 3}, + {age: 48, household: 2, income: 65, education: 2}, + {age: 50, household: 4, income: 325, education: 3}, + {age: 53, household: 2, income: 180, education: 3}, + {age: 56, household: 1, income: 95, education: 3}, + {age: 59, household: 3, income: 120, education: 5}, + {age: 62, household: 2, income: 0, education: 3}, + {age: 65, household: 1, income: 225, education: 4}, + {age: 68, household: 2, income: 30, education: 2}, + {age: 72, household: 1, income: 0, education: 1}, + {age: 76, household: 5, income: 40, education: 2}, + {age: 80, household: 2, income: 20, education: 2}, + {age: 84, household: 1, income: 30, education: 3} +]; diff --git a/test/modules/aggregation-layers/aggregation-layer-v9/test-utils.ts b/test/modules/aggregation-layers/aggregation-layer-v9/test-utils.ts new file mode 100644 index 00000000000..584087dc1ff --- /dev/null +++ b/test/modules/aggregation-layers/aggregation-layer-v9/test-utils.ts @@ -0,0 +1,41 @@ +import {BinaryAttribute} from '@deck.gl/core'; +import {luma} from '@luma.gl/core'; + +export function binaryAttributeToArray( + accessor: BinaryAttribute | null, + length: number +): number[] | null { + if (!accessor) { + return null; + } + + let value = accessor.value; + if (!value) { + const buffer = accessor.buffer; + if (!buffer) { + throw new Error('Invalid binary attribute'); + } + value = new Float32Array(buffer.readSyncWebGL().buffer); + } + const size = accessor.size ?? 1; + const offset = (accessor.offset ?? 0) / 4; + const stride = accessor.stride ? accessor.stride / 4 : size!; + const result: number[] = []; + for (let i = 0; i < length; i++) { + for (let j = 0; j < size; j++) { + result[i * size + j] = value[i * stride + offset + j]; + } + } + return result; +} + +export function getResourceCounts(): Record { + /* global luma */ + const resourceStats = luma.stats.get('Resource Counts'); + return { + Framebuffer: resourceStats.get('Framebuffers Active').count, + Texture2D: resourceStats.get('Texture2Ds Active').count, + Buffer: resourceStats.get('Buffers Active').count, + Shader: resourceStats.get('Shaders Active').count + }; +} diff --git a/test/modules/aggregation-layers/aggregation-layer-v9/webgl-aggregator.spec.ts b/test/modules/aggregation-layers/aggregation-layer-v9/webgl-aggregator.spec.ts new file mode 100644 index 00000000000..ddd6af5e422 --- /dev/null +++ b/test/modules/aggregation-layers/aggregation-layer-v9/webgl-aggregator.spec.ts @@ -0,0 +1,279 @@ +import test from 'tape-promise/tape'; +import {Attribute} from '@deck.gl/core'; +import {WebGLAggregator} from '@deck.gl/aggregation-layers'; +import {device} from '@deck.gl/test-utils'; + +import {IncomeSurvey} from './data-sample'; +import {getResourceCounts, binaryAttributeToArray} from './test-utils'; + +test('WebGLAggregator#resources', t => { + const oldResourceCounts = getResourceCounts(); + // An aggregator that calculates average income grouped by education + const aggregator = new WebGLAggregator(device, { + dimensions: 1, + channelCount: 1, + bufferLayout: [ + {name: 'income', format: 'float32', stepMode: 'vertex'}, + {name: 'education', format: 'float32', stepMode: 'vertex'} + ], + vs: ` + in float income; + in float education; + + void getBin(out int binId) { + binId = int(education); + } + void getValue(out float value) { + value = income; + } + ` + }); + + t.doesNotThrow(() => aggregator.update(), 'Calling update() without setting props'); + t.notOk(aggregator.getResult(0)); + t.notOk(aggregator.getBin(0)); + + const attributes = { + income: new Attribute(device, {id: 'income', size: 1, type: 'float32', accessor: 'getIncome'}), + education: new Attribute(device, { + id: 'education', + size: 1, + type: 'float32', + accessor: 'getEducation' + }) + }; + attributes.income.setData({value: new Float32Array(IncomeSurvey.map(d => d.income))}); + attributes.education.setData({value: new Float32Array(IncomeSurvey.map(d => d.education))}); + + aggregator.setProps({ + pointCount: IncomeSurvey.length, + binIdRange: [[4, 9]], + attributes, + operations: ['MEAN'] + }); + + aggregator.update(); + t.ok(aggregator.getResult(0)); + t.ok(aggregator.getBin(0)); + + // Resize buffers + aggregator.setProps({ + binIdRange: [[0, 15]] + }); + aggregator.update(); + t.ok(aggregator.getResult(0)); + t.ok(aggregator.getBin(0)); + + attributes.income.delete(); + attributes.education.delete(); + aggregator.destroy(); + + t.deepEqual(getResourceCounts(), oldResourceCounts, 'GPU resources are deleted'); + + t.end(); +}); + +test('WebGLAggregator#1D', t => { + // An aggregator that calculates: + // [0] total count [1] average income [2] highest education, grouped by age + const aggregator = new WebGLAggregator(device, { + dimensions: 1, + channelCount: 3, + bufferLayout: [ + {name: 'age', format: 'float32', stepMode: 'vertex'}, + {name: 'income', format: 'float32', stepMode: 'vertex'}, + {name: 'education', format: 'float32', stepMode: 'vertex'} + ], + vs: ` + uniform float ageGroupSize; + in float age; + in float income; + in float education; + + void getBin(out int binId) { + binId = int(floor(age / ageGroupSize)); + } + void getValue(out vec3 value) { + value = vec3(1.0, income, education); + } + ` + }); + + const attributes = { + age: new Attribute(device, {id: 'age', size: 1, type: 'float32', accessor: 'getAge'}), + income: new Attribute(device, {id: 'income', size: 1, type: 'float32', accessor: 'getIncome'}), + education: new Attribute(device, { + id: 'education', + size: 1, + type: 'float32', + accessor: 'getEducation' + }) + }; + attributes.age.setData({value: new Float32Array(IncomeSurvey.map(d => d.age))}); + attributes.income.setData({value: new Float32Array(IncomeSurvey.map(d => d.income))}); + attributes.education.setData({value: new Float32Array(IncomeSurvey.map(d => d.education))}); + + aggregator.setProps({ + pointCount: IncomeSurvey.length, + binIdRange: [[2, 17]], // age: 10..84 + attributes, + operations: ['SUM', 'MEAN', 'MAX'], + binOptions: {ageGroupSize: 5} + }); + + aggregator.update(); + aggregator.preDraw({moduleSettings: {}}); + + t.is(aggregator.binCount, 15, 'binCount'); + + t.deepEqual( + binaryAttributeToArray(aggregator.getBins(), aggregator.binCount), + // prettier-ignore + [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], + 'getBins()' + ); + + t.deepEqual( + binaryAttributeToArray(aggregator.getResult(0), aggregator.binCount), + // prettier-ignore + [NaN, 1, 5, 5, 3, 2, 3, 2, 2, 2, 1, 2, 1, 1, 2], + 'getResult() - total counts' + ); + t.deepEqual(aggregator.getResultDomain(0), [1, 5], 'getResultDomain() - counts'); + + t.deepEqual( + binaryAttributeToArray(aggregator.getResult(1), aggregator.binCount), + // prettier-ignore + [NaN, 25, 48, 54, 100, 145, 250, 72.5, 252.5, 107.5, 0, 127.5, 0, 40, 25], + 'getResult() - mean income' + ); + t.deepEqual(aggregator.getResultDomain(1), [0, 252.5], 'getResultDomain() - mean income'); + + t.deepEqual( + binaryAttributeToArray(aggregator.getResult(2), aggregator.binCount), + // prettier-ignore + [NaN, 1, 3, 4, 5, 4, 5, 3, 3, 5, 3, 4, 1, 2, 3], + 'getResult() - max education' + ); + t.deepEqual(aggregator.getResultDomain(2), [1, 5], 'getResultDomain() - max education'); + + // Empty bin + t.deepEqual( + aggregator.getBin(0), + {id: [2], count: 0, value: [NaN, NaN, NaN]}, + 'getBin() - empty' + ); + // {age: 40, household: 4, income: 140, education: 4}, + // {age: 42, household: 2, income: 110, education: 5}, + // {age: 44, household: 4, income: 500, education: 4}, + t.deepEqual(aggregator.getBin(6), {id: [8], count: 3, value: [3, 250, 5]}, 'getBin()'); + + attributes.age.delete(); + attributes.income.delete(); + attributes.education.delete(); + aggregator.destroy(); + t.end(); +}); + +test('WebGLAggregator#2D', t => { + // An aggregator that calculates: + // [0] total count [1] average income, grouped by [age, education] + const aggregator = new WebGLAggregator(device, { + dimensions: 2, + channelCount: 2, + bufferLayout: [ + {name: 'age', format: 'float32', stepMode: 'vertex'}, + {name: 'income', format: 'float32', stepMode: 'vertex'}, + {name: 'education', format: 'float32', stepMode: 'vertex'} + ], + vs: ` + uniform float ageGroupSize; + in float age; + in float income; + in float education; + + void getBin(out ivec2 binId) { + binId.x = int(floor(age / ageGroupSize)); + binId.y = int(education); + } + void getValue(out vec2 value) { + value = vec2(5.0, income); + } + ` + }); + + const attributes = { + age: new Attribute(device, {id: 'age', size: 1, type: 'float32', accessor: 'getAge'}), + income: new Attribute(device, {id: 'income', size: 1, type: 'float32', accessor: 'getIncome'}), + education: new Attribute(device, { + id: 'education', + size: 1, + type: 'float32', + accessor: 'getEducation' + }) + }; + attributes.age.setData({value: new Float32Array(IncomeSurvey.map(d => d.age))}); + attributes.income.setData({value: new Float32Array(IncomeSurvey.map(d => d.income))}); + attributes.education.setData({value: new Float32Array(IncomeSurvey.map(d => d.education))}); + + aggregator.setProps({ + pointCount: IncomeSurvey.length, + binIdRange: [ + [2, 6], + [1, 6] + ], // age: 20..59, education: 1..5 + attributes, + operations: ['COUNT', 'MEAN'], + binOptions: {ageGroupSize: 10} + }); + + aggregator.update(); + aggregator.preDraw({moduleSettings: {}}); + + t.is(aggregator.binCount, 20, 'binCount'); + + t.deepEqual( + binaryAttributeToArray(aggregator.getBins(), aggregator.binCount), + // prettier-ignore + [ 2, 1, 3, 1, 4, 1, 5, 1, + 2, 2, 3, 2, 4, 2, 5, 2, + 2, 3, 3, 3, 4, 3, 5, 3, + 2, 4, 3, 4, 4, 4, 5, 4, + 2, 5, 3, 5, 4, 5, 5, 5 ], + 'getBins()' + ); + + t.deepEqual( + binaryAttributeToArray(aggregator.getResult(0), aggregator.binCount), + // prettier-ignore + [ NaN, NaN, NaN, NaN, 4, + NaN, 1, NaN, 4, 2, + 1, 3, 2, 2, 2, + NaN, NaN, 1, 1, 1 ], + 'getResult() - total counts' + ); + t.deepEqual(aggregator.getResultDomain(0), [1, 4], 'getResultDomain() - counts'); + + t.deepEqual( + binaryAttributeToArray(aggregator.getResult(1), aggregator.binCount), + // prettier-ignore + [ NaN, NaN, NaN, NaN, 25, + NaN, 65, NaN, 97.5, 90, + 80, 200, 10, 175, 320, + NaN, NaN, 60, 110, 120 ], + 'getResult() - mean income' + ); + t.deepEqual(aggregator.getResultDomain(1), [10, 320], 'getResultDomain() - mean income'); + + // Empty bin + t.deepEqual(aggregator.getBin(0), {id: [2, 1], count: 0, value: [0, NaN]}, 'getBin() - empty'); + // {age: 40, household: 4, income: 140, education: 4}, + // {age: 44, household: 4, income: 500, education: 4}, + t.deepEqual(aggregator.getBin(14), {id: [4, 4], count: 2, value: [2, 320]}, 'getBin()'); + + attributes.age.delete(); + attributes.income.delete(); + attributes.education.delete(); + aggregator.destroy(); + t.end(); +}); diff --git a/test/modules/aggregation-layers/index.ts b/test/modules/aggregation-layers/index.ts index 93dbb028298..6713fe7b763 100644 --- a/test/modules/aggregation-layers/index.ts +++ b/test/modules/aggregation-layers/index.ts @@ -40,3 +40,5 @@ import './utils/bin-sorter.spec'; import './utils/color-utils.spec'; // import './utils/gpu-grid-aggregator.spec'; import './utils/scale-utils.spec'; + +import './aggregation-layer-v9/webgl-aggregator.spec';