diff --git a/modules/engine/src/transform/texture-transform.js b/modules/engine/src/transform/texture-transform.js index ea5a310a4e..922a65eeb0 100644 --- a/modules/engine/src/transform/texture-transform.js +++ b/modules/engine/src/transform/texture-transform.js @@ -47,9 +47,11 @@ export default class TextureTransform { } getDrawOptions(opts = {}) { - const {sourceTextures, framebuffer, targetTexture} = this.bindings[this.currentIndex]; + const {sourceTextures, framebuffer, targetTexture, sourceBuffers} = this.bindings[ + this.currentIndex + ]; - const attributes = Object.assign({}, opts.attributes); + const attributes = Object.assign({}, sourceBuffers, opts.attributes); const uniforms = Object.assign({}, opts.uniforms); const parameters = Object.assign({}, opts.parameters); let discard = opts.discard; @@ -165,14 +167,14 @@ export default class TextureTransform { } _setupTextures(props = {}) { - const {_sourceTextures = {}, _targetTexture} = props; + const {_sourceTextures = {}, _targetTexture, sourceBuffers} = props; const targetTexture = this._createTargetTexture({ sourceTextures: _sourceTextures, textureOrReference: _targetTexture }); this.hasSourceTextures = this.hasSourceTextures || (_sourceTextures && Object.keys(_sourceTextures).length > 0); - this._updateBindings({sourceTextures: _sourceTextures, targetTexture}); + this._updateBindings({sourceTextures: _sourceTextures, targetTexture, sourceBuffers}); if ('elementCount' in props) { this._updateElementIDBuffer(props.elementCount); } @@ -211,14 +213,16 @@ export default class TextureTransform { } _updateBinding(binding, opts) { - const {sourceTextures, targetTexture} = opts; + const {sourceTextures, targetTexture, sourceBuffers} = opts; if (!binding) { binding = { + sourceBuffers: {}, sourceTextures: {}, targetTexture: null }; } Object.assign(binding.sourceTextures, sourceTextures); + Object.assign(binding.sourceBuffers, sourceBuffers); if (targetTexture) { binding.targetTexture = targetTexture; diff --git a/modules/engine/src/transform/transform.js b/modules/engine/src/transform/transform.js index 0faebff69f..e5e00a1841 100644 --- a/modules/engine/src/transform/transform.js +++ b/modules/engine/src/transform/transform.js @@ -153,8 +153,8 @@ export default class Transform { function canCreateBufferTransform(props) { if ( - !isObjectEmpty(props.sourceBuffers) || !isObjectEmpty(props.feedbackBuffers) || + !isObjectEmpty(props.feedbackMap) || (props.varyings && props.varyings.length > 0) ) { return true; diff --git a/modules/experimental/package.json b/modules/experimental/package.json index 529cfe2acb..0c597a04dd 100644 --- a/modules/experimental/package.json +++ b/modules/experimental/package.json @@ -32,7 +32,8 @@ "dependencies": { "@loaders.gl/images": "^2.0.0", "@luma.gl/constants": "8.1.0-alpha.2", - "math.gl": "^3.1.2" + "math.gl": "^3.1.2", + "earcut": "^2.0.6" }, "peerDependencies": { "@loaders.gl/gltf": "^2.0.0", diff --git a/modules/experimental/src/gpgpu/point-in-polygon/gpu-point-in-polygon.js b/modules/experimental/src/gpgpu/point-in-polygon/gpu-point-in-polygon.js new file mode 100644 index 0000000000..513c2fbbe3 --- /dev/null +++ b/modules/experimental/src/gpgpu/point-in-polygon/gpu-point-in-polygon.js @@ -0,0 +1,189 @@ +import GL from '@luma.gl/constants'; +import {Buffer, Texture2D, assert} from '@luma.gl/webgl'; +import {isWebGL2} from '@luma.gl/gltools'; +import {Transform} from '@luma.gl/engine'; +import {default as textureFilterModule} from './texture-filter'; +import {POLY_TEX_VS, FILTER_VS} from './shaders'; +const TEXTURE_SIZE = 512; +import * as Polygon from './polygon'; + +export default class GPUPointInPolygon { + constructor(gl, opts = {}) { + this.gl = gl; + // WebGL2 only + assert(isWebGL2(gl)); + + this.textureSize = opts.textureSize || TEXTURE_SIZE; + this._setupResources(); + + this.update(opts); + } + + update({polygons, textureSize} = {}) { + if (textureSize) { + this.textureSize = textureSize; + } + if (!polygons || polygons.length === 0) { + return; + } + + const {vertices, indices, vertexCount, ids} = triangulatePolygons(polygons); + this._updateResources(vertices, indices, ids, vertexCount); + } + + filter({positionBuffer, filterValueIndexBuffer, count}) { + this.filterTransform.update({ + sourceBuffers: { + a_position: positionBuffer + }, + feedbackBuffers: { + filterValueIndex: filterValueIndexBuffer + }, + elementCount: count + }); + const {polygonTexture, boundingBox} = this; + + this.filterTransform.run({ + moduleSettings: {boundingBox, texture: polygonTexture} + }); + } + + // PRIVATE + + _setupResources() { + const {gl} = this; + + // texture to render polygons to + this.polygonTexture = new Texture2D(gl, { + format: GL.RGB, + type: GL.UNSIGNED_BYTE, + dataFormat: GL.RGB, + border: 0, + mipmaps: false, + parameters: { + [GL.TEXTURE_MAG_FILTER]: GL.NEAREST, + [GL.TEXTURE_MIN_FILTER]: GL.NEAREST, + [GL.TEXTURE_WRAP_S]: gl.CLAMP_TO_EDGE, + [GL.TEXTURE_WRAP_T]: gl.CLAMP_TO_EDGE + } + }); + this.positionBuffer = new Buffer(gl, {accessor: {type: GL.FLOAT, size: 2}}); + this.idBuffer = new Buffer(gl, {accessor: {type: GL.FLOAT, size: 1}}); + this.indexBuffer = new Buffer(gl, { + target: GL.ELEMENT_ARRAY_BUFFER, + accessor: {type: GL.UNSIGNED_SHORT} + }); + + // transform to generate polygon texture + this.polyTextureTransform = new Transform(gl, { + id: `polygon-texture-creation-transform`, + elementCount: 0, + _targetTexture: this.polygonTexture, + _targetTextureVarying: 'v_polygonColor', + vs: POLY_TEX_VS, + drawMode: GL.TRIANGLES, + isIndexed: true, + debug: true, + sourceBuffers: { + a_position: this.positionBuffer, + a_polygonID: this.idBuffer, + indices: this.indexBuffer // key doesn't matter + } + }); + + // transform to perform filtering + this.filterTransform = new Transform(gl, { + id: 'filter transform', + vs: FILTER_VS, + modules: [textureFilterModule], + varyings: ['filterValueIndex'], + debug: true + }); + } + + _updateResources(vertices, indices, ids, vertexCount) { + const boundingBox = getBoundingBox(vertices, vertexCount); + + const width = boundingBox[2] - boundingBox[0]; + const height = boundingBox[3] - boundingBox[1]; + + const whRatio = width / height; + const {textureSize} = this; + + let texWidth = textureSize; + let texHeight = textureSize; + + if (whRatio > 1) { + texHeight = texWidth / whRatio; + } else { + texWidth = texHeight * whRatio; + } + + this.boundingBox = boundingBox; + this.polygonTexture.resize({width: texWidth, height: texHeight, mipmaps: false}); + this.positionBuffer.setData(new Float32Array(vertices)); + this.idBuffer.setData(new Float32Array(ids)); + this.indexBuffer.setData(new Uint16Array(indices)); + this.polyTextureTransform.update({ + elementCount: indices.length, + _targetTexture: this.polygonTexture + }); + + const [xMin, yMin, xMax, yMax] = boundingBox; + this.polyTextureTransform.run({ + uniforms: { + boundingBoxOriginSize: [xMin, yMin, xMax - xMin, yMax - yMin] + } + }); + } +} + +// Helper methods + +function getBoundingBox(positions, vertexCount) { + let yMin = Infinity; + let yMax = -Infinity; + let xMin = Infinity; + let xMax = -Infinity; + let y; + let x; + + for (let i = 0; i < vertexCount; i++) { + x = positions[i * 2]; + y = positions[i * 2 + 1]; + yMin = y < yMin ? y : yMin; + yMax = y > yMax ? y : yMax; + xMin = x < xMin ? x : xMin; + xMax = x > xMax ? x : xMax; + } + + return [xMin, yMin, xMax, yMax]; +} + +function triangulatePolygons(polygons) { + const SIZE = 2; + const vertices = []; + const indices = []; + const ids = []; + let count = 0; + let polygonId = 0; + for (let i = 0; i < polygons.length; i++) { + const normalized = Polygon.normalize(polygons[i], SIZE); + const curVertices = normalized.positions || normalized; + const curIds = new Float32Array(curVertices.length / SIZE).fill(polygonId); + vertices.push(...curVertices); + ids.push(...curIds); + const curIndices = Polygon.getSurfaceIndices(normalized, SIZE); + const indexCount = curIndices.length; + for (let j = 0; j < indexCount; j++) { + curIndices[j] += count; + } + count += curVertices.length / SIZE; + indices.push(...curIndices); + polygonId++; + } + + const vertexCount = Polygon.getVertexCount(vertices, SIZE); + + return {vertices, indices, ids, vertexCount}; +} diff --git a/modules/experimental/src/gpgpu/point-in-polygon/gpu-point-in-polygon.md b/modules/experimental/src/gpgpu/point-in-polygon/gpu-point-in-polygon.md new file mode 100644 index 0000000000..8279b5ea0d --- /dev/null +++ b/modules/experimental/src/gpgpu/point-in-polygon/gpu-point-in-polygon.md @@ -0,0 +1,78 @@ +# GPUPointInPolygon (WebGL2) + +`GPUPointInPolygon` provides GPU accelerated PIP (Point-In-Polygon) testing functionality. A given set of 2D points and one or more 2D polygons, it computes, whether each point is inside or outside of any polygon. + +## Sample Usage +```js + +// construct data into required formats +const polygons = + [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], [-0.5, -0.5]] // polygon vertices +]; + +// XY locations of 6 points +const points = [ + 0, 0, + -0.25, -0.25, + 0.25, -0.25, + 0.25, 0.25, + -0.25, 0.25, + -0.45, 0.45 +]; + +const positionBuffer = new Buffer(gl2, new Float32Array(points)); +const count = 6; +// Allocate result buffer with enough space (2 floats for each point) +const filterValueIndexBuffer = new Buffer(gl2, count * 2 * 4); + +const gpuPointInPolygon = new GPUPointInPolygon(gl2); +gpuPointInPolygon.update({polygons}); +gpuPointInPolygon.filter({positionBuffer, filterValueIndexBuffer, count}); + +const results = filterValueIndexBuffer.getData(); + +// results array contains 2 elements (filterValue, index) for each point, where +// `filterValue` is '-1' if point in outside of polygons, otherwise index of the polygon in which it lies +// `index` is the point index in `positionBuffer` + +``` + +## Constructor + +### GPUPointInPolygon(gl: WebGL2RenderingContext, props: Object) + +Creates a new `gpuPointInPolygon` object. + +* `gl` - (WebGL2RenderingContext) - WebGL2 context. +* `opts.polygons` (`Array`, Optional) - Array of polygons, where each polygon is in following format: + * `Simple polygon` : [[x1, y1], [x2, y2], ...] + * `Polygon with holes` : [ + [[x1, y1], [x2, y2], ...] // outer ring + [[a1, b1], [a2, b2], ...] // hole - 1 + [[s1, t1], [s2, t2], ...] // hole - 2 + ] +* `opts.textureSize` (`Number`, Optional) - Size of the texture to be used to create a polygon texture, that is used internally. Default value is 512. + +## Methods + + +### update(opts) + +* `opts.polygons` (`Array`, Optional) - Array of polygons, where each polygon is in following format: + * `Simple polygon` : [[x1, y1], [x2, y2], ...] + * `Polygon with holes` : [ + [[x1, y1], [x2, y2], ...] // outer ring + [[a1, b1], [a2, b2], ...] // hole - 1 + [[s1, t1], [s2, t2], ...] // hole - 2 + ] +* `opts.textureSize` (`Number`, Optional) - Size of the texture to be used to create a polygon texture, that is used internally. Default value is 512. + +### filter(opts) + +* `opts.positionBuffer` (`Buffer`) - Buffer object containing X, Y position of input points. +* `opts.count` (`Number`) - Number of points to be processed. +* `opts.filterValueIndexBuffer` (`Buffer`) - Buffer object to hold results for each input point. After the method is executed, this buffer contains two floats `filterValue` and `index` for each input point, where : + * `filterValue` is '-1' if point in outside of polygons, else index of the polygon in which it lies + * `index` is the point index in `positionBuffer` + +NOTE: If a point lies in the region that is overlapped by 2 or more polygons, `filterValue` will be index of one of the polygons. diff --git a/modules/experimental/src/gpgpu/point-in-polygon/polygon.js b/modules/experimental/src/gpgpu/point-in-polygon/polygon.js new file mode 100644 index 0000000000..72f5581482 --- /dev/null +++ b/modules/experimental/src/gpgpu/point-in-polygon/polygon.js @@ -0,0 +1,320 @@ +// => COPIED FROM deck.gl + +// Copyright (c) 2015 - 2020 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/* eslint-disable max-params */ +import earcut from 'earcut'; + +// For Web Mercator projection +const PI_4 = Math.PI / 4; +const DEGREES_TO_RADIANS_HALF = Math.PI / 360; + +// 4 data formats are supported: +// Simple Polygon: an array of points +// Complex Polygon: an array of array of points (array of rings) +// with the first ring representing the outer hull and other rings representing holes +// Simple Flat: an array of numbers (flattened "simple polygon") +// Complex Flat: {position: array, holeIndices: array} +// (flattened "complex polygon") + +/** + * Ensure a polygon is valid format + * @param {Array|Object} polygon + */ +function validate(polygon) { + polygon = (polygon && polygon.positions) || polygon; + if (!Array.isArray(polygon) && !ArrayBuffer.isView(polygon)) { + throw new Error('invalid polygon'); + } +} + +/** + * Check if a polygon is simple or complex + * @param {Array} polygon - either a complex or simple polygon + * @return {Boolean} - true if the polygon is a simple polygon (i.e. not an array of polygons) + */ +function isSimple(polygon) { + return polygon.length >= 1 && polygon[0].length >= 2 && Number.isFinite(polygon[0][0]); +} + +/** + * Check if a simple polygon is a closed ring + * @param {Array} simplePolygon - array of points + * @return {Boolean} - true if the simple polygon is a closed ring + */ +function isNestedRingClosed(simplePolygon) { + // check if first and last vertex are the same + const p0 = simplePolygon[0]; + const p1 = simplePolygon[simplePolygon.length - 1]; + + return p0[0] === p1[0] && p0[1] === p1[1] && p0[2] === p1[2]; +} + +/** + * Check if a simple flat array is a closed ring + * @param {Array} positions - array of numbers + * @param {Number} size - size of a position, 2 (xy) or 3 (xyz) + * @param {Number} startIndex - start index of the path in the positions array + * @param {Number} endIndex - end index of the path in the positions array + * @return {Boolean} - true if the simple flat array is a closed ring + */ +function isFlatRingClosed(positions, size, startIndex, endIndex) { + for (let i = 0; i < size; i++) { + if (positions[startIndex + i] !== positions[endIndex - size + i]) { + return false; + } + } + return true; +} + +/** + * Copy a simple polygon coordinates into a flat array, closes the ring if needed. + * @param {Float64Array} target - destination + * @param {Number} targetStartIndex - index in the destination to start copying into + * @param {Array} simplePolygon - array of points + * @param {Number} size - size of a position, 2 (xy) or 3 (xyz) + * @returns {Number} - the index of the write head in the destination + */ +function copyNestedRing(target, targetStartIndex, simplePolygon, size) { + let targetIndex = targetStartIndex; + const len = simplePolygon.length; + for (let i = 0; i < len; i++) { + for (let j = 0; j < size; j++) { + target[targetIndex++] = simplePolygon[i][j] || 0; + } + } + + if (!isNestedRingClosed(simplePolygon)) { + for (let j = 0; j < size; j++) { + target[targetIndex++] = simplePolygon[0][j] || 0; + } + } + return targetIndex; +} + +/** + * Copy a simple flat array into another flat array, closes the ring if needed. + * @param {Float64Array} target - destination + * @param {Number} targetStartIndex - index in the destination to start copying into + * @param {Array} positions - array of numbers + * @param {Number} size - size of a position, 2 (xy) or 3 (xyz) + * @param {Number} [srcStartIndex] - start index of the path in the positions array + * @param {Number} [srcEndIndex] - end index of the path in the positions array + * @returns {Number} - the index of the write head in the destination + */ +function copyFlatRing(target, targetStartIndex, positions, size, srcStartIndex = 0, srcEndIndex) { + srcEndIndex = srcEndIndex || positions.length; + const srcLength = srcEndIndex - srcStartIndex; + if (srcLength <= 0) { + return targetStartIndex; + } + let targetIndex = targetStartIndex; + + for (let i = 0; i < srcLength; i++) { + target[targetIndex++] = positions[srcStartIndex + i]; + } + + if (!isFlatRingClosed(positions, size, srcStartIndex, srcEndIndex)) { + for (let i = 0; i < size; i++) { + target[targetIndex++] = positions[srcStartIndex + i]; + } + } + return targetIndex; +} + +/** + * Counts the number of vertices in a simple polygon, closes the polygon if needed. + * @param {Array} simplePolygon - array of points + * @returns {Number} vertex count + */ +function getNestedVertexCount(simplePolygon) { + return (isNestedRingClosed(simplePolygon) ? 0 : 1) + simplePolygon.length; +} + +/** + * Counts the number of vertices in a simple flat array, closes the polygon if needed. + * @param {Array} positions - array of numbers + * @param {Number} size - size of a position, 2 (xy) or 3 (xyz) + * @param {Number} [startIndex] - start index of the path in the positions array + * @param {Number} [endIndex] - end index of the path in the positions array + * @returns {Number} vertex count + */ +function getFlatVertexCount(positions, size, startIndex = 0, endIndex) { + endIndex = endIndex || positions.length; + if (startIndex >= endIndex) { + return 0; + } + return ( + (isFlatRingClosed(positions, size, startIndex, endIndex) ? 0 : 1) + + (endIndex - startIndex) / size + ); +} + +/** + * Counts the number of vertices in any polygon representation. + * @param {Array|Object} polygon + * @param {Number} positionSize - size of a position, 2 (xy) or 3 (xyz) + * @returns {Number} vertex count + */ +export function getVertexCount(polygon, positionSize, normalization = true) { + if (!normalization) { + polygon = polygon.positions || polygon; + return polygon.length / positionSize; + } + + validate(polygon); + + if (polygon.positions) { + // complex flat + const {positions, holeIndices} = polygon; + + if (holeIndices) { + let vertexCount = 0; + // split the positions array into `holeIndices.length + 1` rings + // holeIndices[-1] falls back to 0 + // holeIndices[holeIndices.length] falls back to positions.length + for (let i = 0; i <= holeIndices.length; i++) { + vertexCount += getFlatVertexCount( + polygon.positions, + positionSize, + holeIndices[i - 1], + holeIndices[i] + ); + } + return vertexCount; + } + polygon = positions; + } + if (Number.isFinite(polygon[0])) { + // simple flat + return getFlatVertexCount(polygon, positionSize); + } + if (!isSimple(polygon)) { + // complex polygon + let vertexCount = 0; + for (const simplePolygon of polygon) { + vertexCount += getNestedVertexCount(simplePolygon); + } + return vertexCount; + } + // simple polygon + return getNestedVertexCount(polygon); +} + +/** + * Normalize any polygon representation into the "complex flat" format + * @param {Array|Object} polygon + * @param {Number} positionSize - size of a position, 2 (xy) or 3 (xyz) + * @param {Number} [vertexCount] - pre-computed vertex count in the polygon. + * If provided, will skip counting. + * @return {Object} - {positions: , holeIndices: } + */ +/* eslint-disable max-statements */ +export function normalize(polygon, positionSize, vertexCount) { + validate(polygon); + + vertexCount = vertexCount || getVertexCount(polygon, positionSize); + + const positions = new Float64Array(vertexCount * positionSize); + const holeIndices = []; + + if (polygon.positions) { + // complex flat + const {positions: srcPositions, holeIndices: srcHoleIndices} = polygon; + + if (srcHoleIndices) { + let targetIndex = 0; + // split the positions array into `holeIndices.length + 1` rings + // holeIndices[-1] falls back to 0 + // holeIndices[holeIndices.length] falls back to positions.length + for (let i = 0; i <= srcHoleIndices.length; i++) { + targetIndex = copyFlatRing( + positions, + targetIndex, + srcPositions, + positionSize, + srcHoleIndices[i - 1], + srcHoleIndices[i] + ); + holeIndices.push(targetIndex); + } + // The last one is not a starting index of a hole, remove + holeIndices.pop(); + + return {positions, holeIndices}; + } + polygon = srcPositions; + } + if (Number.isFinite(polygon[0])) { + // simple flat + copyFlatRing(positions, 0, polygon, positionSize); + return positions; + } + if (!isSimple(polygon)) { + // complex polygon + let targetIndex = 0; + + for (const simplePolygon of polygon) { + targetIndex = copyNestedRing(positions, targetIndex, simplePolygon, positionSize); + holeIndices.push(targetIndex); + } + // The last one is not a starting index of a hole, remove + holeIndices.pop(); + // last index points to the end of the array, remove it + return {positions, holeIndices}; + } + // simple polygon + copyNestedRing(positions, 0, polygon, positionSize); + return positions; +} +/* eslint-enable max-statements */ + +/* + * Get vertex indices for drawing polygon mesh + * @param {Object} normalizedPolygon - {positions, holeIndices} + * @param {Number} positionSize - size of a position, 2 (xy) or 3 (xyz) + * @returns {Array} array of indices + */ +export function getSurfaceIndices(normalizedPolygon, positionSize, preproject) { + let holeIndices = null; + + if (normalizedPolygon.holeIndices) { + holeIndices = normalizedPolygon.holeIndices.map(positionIndex => positionIndex / positionSize); + } + let positions = normalizedPolygon.positions || normalizedPolygon; + + // TODO - handle other coordinate systems and projection modes + if (preproject) { + // When tesselating lnglat coordinates, project them to the Web Mercator plane for accuracy + const n = positions.length; + // Clone the array + positions = positions.slice(); + for (let i = 0; i < n; i += positionSize) { + // project points to a scaled version of the web-mercator plane + // It doesn't matter if x and y are scaled/translated, but the relationship must be linear + const y = positions[i + 1]; + positions[i + 1] = Math.log(Math.tan(PI_4 + y * DEGREES_TO_RADIANS_HALF)); + } + } + + // Let earcut triangulate the polygon + return earcut(positions, holeIndices, positionSize); +} diff --git a/modules/experimental/src/gpgpu/point-in-polygon/shaders.js b/modules/experimental/src/gpgpu/point-in-polygon/shaders.js new file mode 100644 index 0000000000..bbb7f6fe5e --- /dev/null +++ b/modules/experimental/src/gpgpu/point-in-polygon/shaders.js @@ -0,0 +1,25 @@ +export const POLY_TEX_VS = `\ +uniform vec4 boundingBoxOriginSize; //[xMin, yMin, xSize, ySize] +attribute vec2 a_position; +attribute float a_polygonID; +varying vec4 v_polygonColor; +void main() +{ + // translate from bbox to NDC + vec2 pos = a_position - boundingBoxOriginSize.xy; + pos = pos / boundingBoxOriginSize.zw; + pos = pos * 2.0 - vec2(1.0); + gl_Position = vec4(pos, 0.0, 1.0); + v_polygonColor = vec4(a_polygonID, 1.0, 1.0, 1.0); +} +`; + +export const FILTER_VS = `\ +#version 300 es +in vec2 a_position; +out vec2 filterValueIndex; //[x: 0 (outside polygon)/1 (inside), y: position index] +void main() +{ + filterValueIndex = textureFilter_filter(a_position); +} +`; diff --git a/modules/experimental/src/gpgpu/point-in-polygon/texture-filter.js b/modules/experimental/src/gpgpu/point-in-polygon/texture-filter.js new file mode 100644 index 0000000000..0b80c9515f --- /dev/null +++ b/modules/experimental/src/gpgpu/point-in-polygon/texture-filter.js @@ -0,0 +1,39 @@ +// shader module to perform texture filtering + +const vs = ` +uniform vec4 textureFilter_uBoundingBox; //[xMin, yMin, xSize, ySize] +uniform sampler2D textureFilter_uTexture; +vec2 textureFilter_filter(vec2 position) { + vec2 filterValueIndex; + // Transfrom 'pos' to texture coordinate + vec2 pos = position - textureFilter_uBoundingBox.xy; + pos = pos / textureFilter_uBoundingBox.zw; + filterValueIndex.y = float(gl_VertexID); + if (pos.x < 0. || pos.x > 1. || pos.y < 0. || pos.y > 1.) { + filterValueIndex.x = -1.; + } else { + // Red channel is ID, Green channel inside/outside polygon + vec4 color = texture(textureFilter_uTexture, pos.xy); + filterValueIndex.x = color.g > 0. ? color.r : -1.; + } + return filterValueIndex; +} +`; + +function getUniforms(opts = {}) { + const uniforms = {}; + if (opts.boundingBox) { + const [xMin, yMin, xMax, yMax] = opts.boundingBox; + uniforms.textureFilter_uBoundingBox = [xMin, yMin, xMax - xMin, yMax - yMin]; + } + if (opts.texture) { + uniforms.textureFilter_uTexture = opts.texture; + } + return uniforms; +} + +export default { + name: 'texture-filter', + vs, + getUniforms +}; diff --git a/modules/experimental/src/index.js b/modules/experimental/src/index.js index 9d60e0fbb5..bcd927a15f 100644 --- a/modules/experimental/src/index.js +++ b/modules/experimental/src/index.js @@ -17,3 +17,5 @@ export { getHistoPyramid, histoPyramidGenerateIndices } from './gpgpu/histopyramid/histopyramid'; + +export {default as GPUPointInPolygon} from './gpgpu/point-in-polygon/gpu-point-in-polygon'; diff --git a/modules/experimental/test/gpgpu/index.js b/modules/experimental/test/gpgpu/index.js index 879bdfaefe..9e77b5fe5a 100644 --- a/modules/experimental/test/gpgpu/index.js +++ b/modules/experimental/test/gpgpu/index.js @@ -1 +1,2 @@ import './histopyramid.spec'; +import './point-in-polygon/point-in-polygon.spec'; diff --git a/modules/experimental/test/gpgpu/point-in-polygon/cpu-point-in-polygon.js b/modules/experimental/test/gpgpu/point-in-polygon/cpu-point-in-polygon.js new file mode 100644 index 0000000000..3730bb86fe --- /dev/null +++ b/modules/experimental/test/gpgpu/point-in-polygon/cpu-point-in-polygon.js @@ -0,0 +1,23 @@ +import booleanWithin from '@turf/boolean-within'; +import {point as turfPoint, polygon as turfPolygon} from '@turf/helpers'; + +export function cpuPointInPolygon({polygons, points}) { + const turfPolygons = new Array(polygons.length); + for (let i = 0; i < polygons.length; i++) { + // convert to required turf polygon format. + const polygon = Number.isFinite(polygons[i][0][0]) ? [polygons[i]] : polygons[i]; + turfPolygons[i] = turfPolygon(polygon); + } + const pointCount = points.length; + const filterValues = new Float32Array(pointCount); + for (let i = 0; i < pointCount; i++) { + let polygonId = -1; + for (let j = 0; j < turfPolygons.length; j++) { + if (booleanWithin(turfPoint(points[i]), turfPolygons[j])) { + polygonId = j; + } + } + filterValues[i] = polygonId; + } + return filterValues; +} diff --git a/modules/experimental/test/gpgpu/point-in-polygon/point-in-polygon.spec.js b/modules/experimental/test/gpgpu/point-in-polygon/point-in-polygon.spec.js new file mode 100644 index 0000000000..2390c21301 --- /dev/null +++ b/modules/experimental/test/gpgpu/point-in-polygon/point-in-polygon.spec.js @@ -0,0 +1,205 @@ +import test from 'tape-catch'; +import {fixture} from 'test/setup'; +import {equals} from 'math.gl'; +import {Buffer} from '@luma.gl/webgl'; +import {GPUPointInPolygon} from '@luma.gl/experimental'; +import {cpuPointInPolygon} from './cpu-point-in-polygon'; + +const {gl2} = fixture; +const TEST_CASES = [ + { + name: 'all - in', + polygons: [[[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], [-0.5, -0.5]]], + points: [[0, 0], [-0.25, -0.25], [0.25, -0.25], [0.25, 0.25], [-0.25, 0.25], [-0.45, 0.45]] + }, + { + name: 'all - out', + polygons: [[[-0.35, -0.35], [0.35, -0.35], [0.35, 0.35], [-0.35, 0.35], [-0.35, -0.35]]], + points: [[-0.45, -0.25], [0.25, -0.5], [0.45, 0.25], [-0.25, 0.45], [-0.45, 0.45], [10, 0]] + }, + { + name: 'mix', + polygons: [[[-0.35, -0.35], [0.45, -0.35], [0.35, 0.45], [-0.5, 0.35], [-0.35, -0.35]]], + points: [ + [-0.35, -0.35], + [0.25, -0.45], + [0.25, -0.25], + [0.45, 0.25], + [0.34, 0.43], + // [-0.25, 0.45], // on polygon edge, gives different results for CPU and GPU + [-0.25, 0.5], + [-0.45, 0.45], + [0.33, 0.44], + [10, 5], + [0, 0], + [-0.35, -0.35] + ] + }, + { + name: 'mix - 2', + polygons: [ + [ + [1, -4], + [2, -1], + [5, -3], + [3, -1], + [4, 2], + [1, 10], + [-1, 1], + [-5, 4], + [-4, -1], + [-25, -4], + [1, -4] + ] + ], + points: [ + [0, 0], + [1, 0], + [5, 0], + [0.5, -3], + [-0.5, -30], + [10, -1], + [1, 7], + [2, 10], + [-100, 20], + [-3, 1] + ], + scales: [2, -0.5, 100] + }, + { + // randomly generated data + name: 'random', + polygons: [ + [ + [0.8473027063236651, -86.3501734165155], + [3.7518798663936996, -84.40338495589492], + [6.522768228802835, -85.29902707329609], + [7.8754470219566866, -89.25407431816627], + [2.7759858381233693, -90.37746599216538], + [3.5036751881932595, -93.02013379664764], + [-0.38079306661700824, -90.8155507382388], + [2.2717464958411537, -98.51490448745484], + [-0.5210318674069924, -95.08886537312374], + [-4.023826773093546, -97.07503233729264], + [-5.651509453124722, -97.19591327984945], + [-5.209427502337417, -93.32382544933547], + [-3.1986527853331252, -90.70815651194543], + [-4.140762974650901, -89.34677058551321], + [-2.5125501924329807, -89.02077203843078], + [-4.692806467765156, -84.38578795758295], + [-1.5149260127239457, -84.61786523331578], + [0.8473027063236651, -86.3501734165155] + ], + [ + [30.454449898171493, -86.00897222844014], + [33.11940967909238, -93.64781184616005], + [24.599776599121007, -93.31646641453531], + [23.245264896876385, -93.21031310065165], + [17.85964570761259, -87.26601424338588], + [24.256739038165286, -90.33321457812242], + [30.454449898171493, -86.00897222844014] + ] + ], + points: [ + [-3.354224681854248, -84.14159393310547], // close to the border outside + [-3.354224681854248, -85.14159393310547], // close to the border inside + + [2.142691516876221, -93.45799255371094], // inside bbox, outside polygon + + [3.7231504917144775, -87.21636962890625], // inside + + [29.476238250732422, -77.00528717041016], // outside + [20.560392379760742, -92.24445343017578], // outside + + [29.531993865966797, -87.56260681152344], // inside + + [32.13725280761719, -89.35847473144531], // outside + + [18.95863037109375, -88.05795288085938], // inside + + [-4.68645715713501, -93.49331665039062], // inside + + [30.752777099609375, -90.89189910888672] // inside + ] + }, + { + name: 'geo', + polygons: [ + [ + [37.7655, -122.4293], + [37.7773, -122.4173], + [37.7664, -122.4143], + [37.7564, -122.409], + [37.7625, -122.419], + [37.7655, -122.4293] + ] + ], + points: [ + [37.7671, -122.4274], + [37.7811, -122.4446], + [37.7657, -122.4322], + [37.7657, 122.4322], + [40.7663, -74.0024] + ] + }, + { + name: 'polygon with hole', + polygons: [ + [ + [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5], [-0.5, -0.5]], // outer ring + [[-0.25, -0.25], [0.25, -0.25], [0.25, 0.25], [-0.25, 0.25], [-0.25, -0.25]] // hole + ] + ], + points: [[0, 0], [-0.35, -0.25], [0.15, -0.15], [0.25, 0.35], [-0.75, 0.25], [-0.45, 0.45]] + } +]; + +/* eslint-disable max-nested-callbacks */ +test('gpgpu#GPUPointInPolygon CPU vs GPU', t => { + if (!gl2) { + t.comment('WebGL2 not available, skipping tests'); + t.end(); + return; + } + const gpuPointInPolygon = new GPUPointInPolygon(gl2, {textureSize: 512}); + + TEST_CASES.forEach(tc => { + const scales = tc.scales || [1]; + scales.forEach(scale => { + const polygons = tc.polygons || [tc.polygon]; + const points = tc.points.map(xy => [xy[0] * scale, xy[1] * scale]); + const name = `${tc.name} scale:${scale}`; + + const count = points.length; + const flatArray = new Float32Array(count * 2); + for (let i = 0; i < count; i++) { + flatArray[i * 2] = points[i][0]; + flatArray[i * 2 + 1] = points[i][1]; + } + + // gpu + const positionBuffer = new Buffer(gl2, flatArray); + const filterValueIndexBuffer = new Buffer(gl2, count * 2 * 4); + gpuPointInPolygon.update({polygons}); + gpuPointInPolygon.filter({positionBuffer, filterValueIndexBuffer, count}); + const filterValueIndexArray = filterValueIndexBuffer.getData(); + const gpuResults = new Array(count); + for (let i = 0; i < count; i++) { + const index = filterValueIndexArray[i * 2 + 1]; + gpuResults[index] = filterValueIndexArray[i * 2]; + } + positionBuffer.delete(); + filterValueIndexBuffer.delete(); + + // cpu + let cpuResults = tc.cpuResults; + if (!cpuResults) { + cpuResults = cpuPointInPolygon({polygons, points}); + } + + t.ok(equals(gpuResults, cpuResults), `${name}: CPU GPU results should match`); + }); + }); + t.end(); +}); +/* eslint-enable max-nested-callbacks */ diff --git a/package.json b/package.json index 279ee06eeb..1087f9bb7d 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,9 @@ "raw-loader": "^0.5.1", "reify": "^0.18.1", "source-map-support": "^0.4.2", - "tape-promise": "^1.1.0" + "tape-promise": "^1.1.0", + "@turf/boolean-within": "^6.0.1", + "@turf/helpers": "^6.1.4" }, "pre-commit": [ "test-fast" diff --git a/test/bench/index.js b/test/bench/index.js index 71d8f7ee7f..62b2d7c02b 100644 --- a/test/bench/index.js +++ b/test/bench/index.js @@ -24,13 +24,14 @@ import {Bench} from '@probe.gl/bench'; import shadersBench from './shaders.bench'; import uniformsBench from './uniforms.bench'; import arrayCopyBench from './array-copy.bench'; - +import pipBench from './point-in-polygon.bench'; const suite = new Bench(); // add tests uniformsBench(suite); shadersBench(suite); arrayCopyBench(suite); +pipBench(suite); // Run the suite suite.run(); diff --git a/test/bench/point-in-polygon.bench.js b/test/bench/point-in-polygon.bench.js new file mode 100644 index 0000000000..587034f361 --- /dev/null +++ b/test/bench/point-in-polygon.bench.js @@ -0,0 +1,142 @@ +// Copyright (c) 2015 - 2020 Uber Technologies, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import {GPUPointInPolygon} from '@luma.gl/experimental'; +import {Buffer} from '@luma.gl/webgl'; +import {createTestContext} from '@luma.gl/test-utils'; +import {cpuPointInPolygon} from '../../modules/experimental/test/gpgpu/point-in-polygon/cpu-point-in-polygon'; +const gl = createTestContext(); + +const random = getRandom(); + +const Count10K = 10000; +const Count1M = 1000000; +const Count10M = 10000000; +const polygon5 = getRandomPolygon(4); +const polygon10 = getRandomPolygon(10); +const points10K = getRandomPoints(Count10K); +const points1M = getRandomPoints(Count1M); +const points10M = getRandomPoints(Count10M); + +const positionBuffer10K = new Buffer(gl, points10K.flatArray); +const filterValueIndexBuffer10K = new Buffer(gl, Count10K * 2 * 4); +const positionBuffer1M = new Buffer(gl, points1M.flatArray); +const filterValueIndexBuffer1M = new Buffer(gl, Count1M * 2 * 4); +const positionBuffer10M = new Buffer(gl, points10M.flatArray); +const filterValueIndexBuffer10M = new Buffer(gl, Count10M * 2 * 4); + +const gpuPolygonClip = new GPUPointInPolygon(gl, {textureSize: 512}); + +export default function pointInPolygonBench(suite) { + return suite + + .group('Point-In-Polygon') + + .add('CPU: 10K points, 5 polygon edges', () => { + cpuPointInPolygon({polygons: [polygon5], points: points10K.pointsArray}); + }) + .add('GPU: 10K points, 5 polygon edges', () => { + gpuPolygonClip.update({polygons: [polygon5]}); + gpuPolygonClip.filter({ + positionBuffer: positionBuffer10K, + filterValueIndexBuffer: filterValueIndexBuffer10K, + count: Count10K + }); + }) + .add('CPU: 10K points, 10 polygon edges', () => { + cpuPointInPolygon({polygons: [polygon10], points: points10K.pointsArray}); + }) + .add('GPU: 10K points, 10 polygon edges', () => { + gpuPolygonClip.update({polygons: [polygon10]}); + gpuPolygonClip.filter({ + positionBuffer: positionBuffer10K, + filterValueIndexBuffer: filterValueIndexBuffer10K, + count: Count10K + }); + }) + .add('CPU: 1M points, 10 polygon edges', () => { + cpuPointInPolygon({polygons: [polygon10], points: points1M.pointsArray}); + }) + .add('GPU: 1M points, 10 polygon edges', () => { + gpuPolygonClip.update({polygons: [polygon10]}); + gpuPolygonClip.filter({ + positionBuffer: positionBuffer1M, + filterValueIndexBuffer: filterValueIndexBuffer1M, + count: Count1M + }); + }) + .add('CPU: 10M points, 10 polygon edges', () => { + cpuPointInPolygon({polygons: [polygon10], points: points10M.pointsArray}); + }) + .add('GPU: 10M points, 10 polygon edges', () => { + gpuPolygonClip.update({polygons: [polygon10]}); + gpuPolygonClip.filter({ + positionBuffer: positionBuffer10M, + filterValueIndexBuffer: filterValueIndexBuffer10M, + count: Count10M + }); + }); +} + +function getRandomPoints(count) { + const flatArray = new Float32Array(count * 2); + const pointsArray = new Array(count); + for (let i = 0; i < count; ++i) { + flatArray[i * 2] = random() * 2.0 - 1.0; + flatArray[i * 2 + 1] = random() * 2.0 - 1.0; + pointsArray[i] = [flatArray[i * 2], flatArray[i * 2 + 1]]; + } + return {flatArray, pointsArray}; +} + +export function getRandomPolygon(size) { + size = size || Math.floor(random() * 50); + size = Math.max(size, 4); + const angleStep = 360 / size; + let angle = 0; + const radiusStep = 0.25; + let radius = 0; + const polygon = []; + const xOffset = (random() - 0.5) / 4; + const yOffset = (random() - 0.5) / 4; + for (let i = 0; i < size; i++) { + radius = 0.25 + radiusStep * random(); // random value between 0.25 to 0.5 + angle = angleStep * i + angleStep * random(); + const cos = Math.cos((angle * Math.PI) / 180); + const sin = Math.sin((angle * Math.PI) / 180); + polygon.push([radius * sin + xOffset, radius * cos + yOffset]); + } + polygon.push([...polygon[0]]); + return polygon; +} + +function getRandom() { + let s = 1; + let c = 1; + return () => { + s = Math.sin(c * 17.23); + c = Math.cos(s * 27.92); + return fract(Math.abs(s * c) * 1432.71); + }; +} + +function fract(n) { + return n - Math.floor(n); +} diff --git a/yarn.lock b/yarn.lock index 508d5091d5..d5db818c04 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1609,6 +1609,60 @@ probe.gl "3.2.0" puppeteer "^1.16.0" +"@turf/bbox@6.x": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.0.1.tgz#b966075771475940ee1c16be2a12cf389e6e923a" + integrity sha512-EGgaRLettBG25Iyx7VyUINsPpVj1x3nFQFiGS3ER8KCI1MximzNLsam3eXRabqQDjyAKyAE1bJ4EZEpGvspQxw== + dependencies: + "@turf/helpers" "6.x" + "@turf/meta" "6.x" + +"@turf/boolean-point-in-polygon@6.x": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.0.1.tgz#5836677afd77d2ee391af0056a0c29b660edfa32" + integrity sha512-FKLOZ124vkJhjzNSDcqpwp2NvfnsbYoUOt5iAE7uskt4svix5hcjIEgX9sELFTJpbLGsD1mUbKdfns8tZxcMNg== + dependencies: + "@turf/helpers" "6.x" + "@turf/invariant" "6.x" + +"@turf/boolean-point-on-line@6.x": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-6.0.1.tgz#d943c242a5fdcde03f8ad0221750fd1aacf06223" + integrity sha512-Vl724Tzh4CF/13kgblOAQnMcHagromCP1EfyJ9G/8SxpSoTYeY2G6FmmcpbW51GqKxC7xgM9+Pck50dun7oYkg== + dependencies: + "@turf/helpers" "6.x" + "@turf/invariant" "6.x" + +"@turf/boolean-within@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@turf/boolean-within/-/boolean-within-6.0.1.tgz#eac2ebe4962e840dd16f0dc56486469eeb92975f" + integrity sha512-fAzDoWzA4UvUE99G8VqQjVg+PSrPBACM9+SLcl0vkbxIhTjoknpTUwSfH86EgKiCkTDttiDIs/q27xZ4H+mgLQ== + dependencies: + "@turf/bbox" "6.x" + "@turf/boolean-point-in-polygon" "6.x" + "@turf/boolean-point-on-line" "6.x" + "@turf/helpers" "6.x" + "@turf/invariant" "6.x" + +"@turf/helpers@6.x", "@turf/helpers@^6.1.4": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.1.4.tgz#d6fd7ebe6782dd9c87dca5559bda5c48ae4c3836" + integrity sha512-vJvrdOZy1ngC7r3MDA7zIGSoIgyrkWcGnNIEaqn/APmw+bVLF2gAW7HIsdTxd12s5wQMqEpqIQrmrbRRZ0xC7g== + +"@turf/invariant@6.x": + version "6.1.2" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.1.2.tgz#6013ed6219f9ac2edada9b31e1dfa5918eb0a2f7" + integrity sha512-WU08Ph8j0J2jVGlQCKChXoCtI50BB3yEH21V++V0T4cR1T27HKCxkehV2sYMwTierfMBgjwSwDIsxnR4/2mWXg== + dependencies: + "@turf/helpers" "6.x" + +"@turf/meta@6.x": + version "6.0.2" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.0.2.tgz#eb92951126d24a613ac1b7b99d733fcc20fd30cf" + integrity sha512-VA7HJkx7qF1l3+GNGkDVn2oXy4+QoLP6LktXAaZKjuT1JI0YESat7quUkbCMy4zP9lAUuvS4YMslLyTtr919FA== + dependencies: + "@turf/helpers" "6.x" + "@types/events@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" @@ -3684,6 +3738,11 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" +earcut@^2.0.6: + version "2.2.2" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.2.tgz#41b0bc35f63e0fe80da7cddff28511e7e2e80d11" + integrity sha512-eZoZPPJcUHnfRZ0PjLvx2qBordSiO8ofC3vt+qACLM95u+4DovnbYNpQtJh0DNsWj8RnxrQytD4WA8gj5cRIaQ== + earcut@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.1.tgz#3bae0b1b6fec41853b56b126f03a42a34b28f1d5"