diff --git a/examples/icon-sprite-webgl.html b/examples/icon-sprite-webgl.html index f465510d4c..c6dc366cd0 100644 --- a/examples/icon-sprite-webgl.html +++ b/examples/icon-sprite-webgl.html @@ -18,3 +18,4 @@ cloak: value: Your Mapbox access token from https://mapbox.com/ here ---
+
Current sighting:
diff --git a/examples/icon-sprite-webgl.js b/examples/icon-sprite-webgl.js index 7c0e1da6bd..08f875e634 100644 --- a/examples/icon-sprite-webgl.js +++ b/examples/icon-sprite-webgl.js @@ -103,7 +103,7 @@ function loadData() { loadData(); -new Map({ +const map = new Map({ layers: [ new TileLayer({ source: new TileJSON({ @@ -121,3 +121,18 @@ new Map({ zoom: 2 }) }); + +const info = document.getElementById('info'); +map.on('pointermove', function(evt) { + if (map.getView().getInteracting()) { + return; + } + const pixel = evt.pixel; + info.innerText = ''; + map.forEachFeatureAtPixel(pixel, function(feature) { + const datetime = feature.get('datetime'); + const duration = feature.get('duration'); + const shape = feature.get('shape'); + info.innerText = 'On ' + datetime + ', lasted ' + duration + ' seconds and had a "' + shape + '" shape.'; + }); +}); diff --git a/src/ol/renderer/Layer.js b/src/ol/renderer/Layer.js index cde8519918..9c0187c1e7 100644 --- a/src/ol/renderer/Layer.js +++ b/src/ol/renderer/Layer.js @@ -132,15 +132,6 @@ class LayerRenderer extends Observable { } } - /** - * @param {import("../coordinate.js").Coordinate} coordinate Coordinate. - * @param {import("../PluggableMap.js").FrameState} frameState Frame state. - * @return {boolean} Is there a feature at the given coordinate? - */ - hasFeatureAtCoordinate(coordinate, frameState) { - return false; - } - /** * Load the image if not already loaded, and register the image change * listener if needed. diff --git a/src/ol/renderer/webgl/Layer.js b/src/ol/renderer/webgl/Layer.js index b50b54d1b2..c8e6cbbc20 100644 --- a/src/ol/renderer/webgl/Layer.js +++ b/src/ol/renderer/webgl/Layer.js @@ -81,6 +81,7 @@ class WebGLLayerRenderer extends LayerRenderer { getShaderCompileErrors() { return this.helper.getShaderCompileErrors(); } + } @@ -231,11 +232,47 @@ export function writePointFeatureToBuffers(instructions, elementIndex, vertexBuf * @private * @return {ImageData} Image data. */ -export function getBlankTexture() { +export function getBlankImageData() { const canvas = document.createElement('canvas'); const image = canvas.getContext('2d').createImageData(1, 1); image.data[0] = image.data[1] = image.data[2] = image.data[3] = 255; return image; } +/** + * Generates a color array based on a numerical id + * Note: the range for each component is 0 to 1 with 256 steps + * @param {number} id Id + * @param {Array} [opt_array] Reusable array + * @return {Array} Color array containing the encoded id + */ +export function colorEncodeId(id, opt_array) { + const array = opt_array || []; + const radix = 256; + const divide = radix - 1; + array[0] = Math.floor(id / radix / radix / radix) / divide; + array[1] = (Math.floor(id / radix / radix) % radix) / divide; + array[2] = (Math.floor(id / radix) % radix) / divide; + array[3] = (id % radix) / divide; + return array; +} + + +/** + * Reads an id from a color-encoded array + * Note: the expected range for each component is 0 to 1 with 256 steps. + * @param {Array} color Color array containing the encoded id + * @return {number} Decoded id + */ +export function colorDecodeId(color) { + let id = 0; + const radix = 256; + const mult = radix - 1; + id += Math.round(color[0] * radix * radix * radix * mult); + id += Math.round(color[1] * radix * radix * mult); + id += Math.round(color[2] * radix * mult); + id += Math.round(color[3] * mult); + return id; +} + export default WebGLLayerRenderer; diff --git a/src/ol/renderer/webgl/PointsLayer.js b/src/ol/renderer/webgl/PointsLayer.js index 05dab3ef5f..6f430792c5 100644 --- a/src/ol/renderer/webgl/PointsLayer.js +++ b/src/ol/renderer/webgl/PointsLayer.js @@ -6,7 +6,9 @@ import {DYNAMIC_DRAW, ARRAY_BUFFER, ELEMENT_ARRAY_BUFFER, FLOAT} from '../../web import {DefaultAttrib, DefaultUniform} from '../../webgl/Helper.js'; import GeometryType from '../../geom/GeometryType.js'; import WebGLLayerRenderer, { - getBlankTexture, + colorDecodeId, + colorEncodeId, + getBlankImageData, POINT_INSTRUCTIONS_COUNT, POINT_VERTEX_STRIDE, WebGLWorkerMessageType, writePointFeatureInstructions } from './Layer.js'; @@ -19,6 +21,8 @@ import { apply as applyTransform } from '../../transform.js'; import {create as createWebGLWorker} from '../../worker/webgl.js'; +import {getUid} from '../../util.js'; +import WebGLRenderTarget from '../../webgl/RenderTarget.js'; const VERTEX_SHADER = ` precision mediump float; @@ -68,6 +72,26 @@ const FRAGMENT_SHADER = ` gl_FragColor.rgb *= gl_FragColor.a; }`; +const HIT_FRAGMENT_SHADER = ` + precision mediump float; + + uniform sampler2D u_texture; + + varying vec2 v_texCoord; + varying float v_opacity; + varying vec4 v_color; + + void main(void) { + if (v_opacity == 0.0) { + discard; + } + vec4 textureColor = texture2D(u_texture, v_texCoord); + if (textureColor.a < 0.1) { + discard; + } + gl_FragColor = v_color; + }`; + /** * @typedef {Object} Options * @property {function(import("../../Feature").default):number} [sizeCallback] Will be called on every feature in the @@ -200,7 +224,7 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { const options = opt_options || {}; const uniforms = options.uniforms || {}; - uniforms.u_texture = options.texture || getBlankTexture(); + uniforms.u_texture = options.texture || getBlankImageData(); const projectionMatrixTransform = createTransform(); uniforms[DefaultUniform.PROJECTION_MATRIX] = projectionMatrixTransform; @@ -212,14 +236,17 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { this.sourceRevision_ = -1; this.verticesBuffer_ = new WebGLArrayBuffer(ARRAY_BUFFER, DYNAMIC_DRAW); + this.hitVerticesBuffer_ = new WebGLArrayBuffer(ARRAY_BUFFER, DYNAMIC_DRAW); this.indicesBuffer_ = new WebGLArrayBuffer(ELEMENT_ARRAY_BUFFER, DYNAMIC_DRAW); this.program_ = this.helper.getProgram( options.fragmentShader || FRAGMENT_SHADER, options.vertexShader || VERTEX_SHADER ); - - this.helper.useProgram(this.program_); + this.hitProgram_ = this.helper.getProgram( + HIT_FRAGMENT_SHADER, + options.vertexShader || VERTEX_SHADER + ); this.sizeCallback_ = options.sizeCallback || function() { return 1; @@ -274,21 +301,55 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { */ this.renderInstructions_ = new Float32Array(0); + /** + * @type {import("../../transform.js").Transform} + * @private + */ + this.hitRenderTransform_ = createTransform(); + + /** + * @type {import("../../transform.js").Transform} + * @private + */ + this.invertHitRenderTransform_ = createTransform(); + + /** + * These instructions are used for hit detection + * @type {Float32Array} + * @private + */ + this.hitRenderInstructions_ = new Float32Array(0); + + /** + * @type {WebGLRenderTarget} + * @private + */ + this.hitRenderTarget_ = new WebGLRenderTarget(this.helper); + this.worker_ = createWebGLWorker(); this.worker_.addEventListener('message', function(event) { const received = event.data; if (received.type === WebGLWorkerMessageType.GENERATE_BUFFERS) { const projectionTransform = received.projectionTransform; - this.verticesBuffer_.fromArrayBuffer(received.vertexBuffer); + if (received.hitDetection) { + this.hitVerticesBuffer_.fromArrayBuffer(received.vertexBuffer); + this.helper.flushBufferData(this.hitVerticesBuffer_); + } else { + this.verticesBuffer_.fromArrayBuffer(received.vertexBuffer); + this.helper.flushBufferData(this.verticesBuffer_); + } this.indicesBuffer_.fromArrayBuffer(received.indexBuffer); - this.helper.flushBufferData(this.verticesBuffer_); this.helper.flushBufferData(this.indicesBuffer_); - // saves the projection transform for the current frame state - this.renderTransform_ = projectionTransform; - makeInverseTransform(this.invertRenderTransform_, this.renderTransform_); - - this.renderInstructions_ = new Float32Array(event.data.renderInstructions); + if (received.hitDetection) { + this.hitRenderTransform_ = projectionTransform; + makeInverseTransform(this.invertHitRenderTransform_, this.hitRenderTransform_); + this.hitRenderInstructions_ = new Float32Array(event.data.renderInstructions); + } else { + this.renderTransform_ = projectionTransform; + makeInverseTransform(this.invertRenderTransform_, this.renderTransform_); + this.renderInstructions_ = new Float32Array(event.data.renderInstructions); + } } }.bind(this)); } @@ -315,6 +376,9 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { canvas.style.opacity = opacity; } + this.renderHitDetection(frameState); + this.hitRenderTarget_.clearCachedData(); + return canvas; } @@ -349,6 +413,7 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { this.helper.makeProjectionTransform(frameState, this.currentTransform_); multiplyTransform(this.currentTransform_, this.invertRenderTransform_); + this.helper.useProgram(this.program_); this.helper.prepareDraw(frameState); // write new data @@ -384,11 +449,16 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { if (!this.renderInstructions_ || this.renderInstructions_.length !== totalInstructionsCount) { this.renderInstructions_ = new Float32Array(totalInstructionsCount); } + if (!this.hitRenderInstructions_ || this.hitRenderInstructions_.length !== totalInstructionsCount) { + this.hitRenderInstructions_ = new Float32Array(totalInstructionsCount); + } // loop on features to fill the buffer let feature; const tmpCoords = []; + const tmpColor = []; let elementIndex = 0; + let u0, v0, u1, v1, size, opacity, rotateWithView, color; for (let i = 0; i < features.length; i++) { feature = features[i]; if (!feature.getGeometry() || feature.getGeometry().getType() !== GeometryType.POINT) { @@ -399,19 +469,45 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { tmpCoords[1] = this.coordCallback_(feature, 1); applyTransform(projectionTransform, tmpCoords); - elementIndex = writePointFeatureInstructions( + u0 = this.texCoordCallback_(feature, 0); + v0 = this.texCoordCallback_(feature, 1); + u1 = this.texCoordCallback_(feature, 2); + v1 = this.texCoordCallback_(feature, 3); + size = this.sizeCallback_(feature); + opacity = this.opacityCallback_(feature); + rotateWithView = this.rotateWithViewCallback_(feature); + color = this.colorCallback_(feature, this.colorArray_); + + writePointFeatureInstructions( this.renderInstructions_, elementIndex, tmpCoords[0], tmpCoords[1], - this.texCoordCallback_(feature, 0), - this.texCoordCallback_(feature, 1), - this.texCoordCallback_(feature, 2), - this.texCoordCallback_(feature, 3), - this.sizeCallback_(feature), - this.opacityCallback_(feature), - this.rotateWithViewCallback_(feature), - this.colorCallback_(feature, this.colorArray_) + u0, + v0, + u1, + v1, + size, + opacity, + rotateWithView, + color + ); + + // for hit detection, the feature uid is saved in the opacity value + // and the index of the opacity value is encoded in the color values + elementIndex = writePointFeatureInstructions( + this.hitRenderInstructions_, + elementIndex, + tmpCoords[0], + tmpCoords[1], + u0, + v0, + u1, + v1, + size, + opacity > 0 ? Number(getUid(feature)) : 0, + rotateWithView, + colorEncodeId(elementIndex + 7, tmpColor) ); } @@ -422,12 +518,80 @@ class WebGLPointsLayerRenderer extends WebGLLayerRenderer { }; // additional properties will be sent back as-is by the worker message['projectionTransform'] = projectionTransform; - this.worker_.postMessage(message, [this.renderInstructions_.buffer]); this.renderInstructions_ = null; + + /** @type import('./Layer').WebGLWorkerGenerateBuffersMessage */ + const hitMessage = { + type: WebGLWorkerMessageType.GENERATE_BUFFERS, + renderInstructions: this.hitRenderInstructions_.buffer + }; + hitMessage['projectionTransform'] = projectionTransform; + hitMessage['hitDetection'] = true; + this.worker_.postMessage(hitMessage, [this.hitRenderInstructions_.buffer]); + this.hitRenderInstructions_ = null; } + /** + * @inheritDoc + */ + forEachFeatureAtCoordinate(coordinate, frameState, hitTolerance, callback, declutteredFeatures) { + if (!this.hitRenderInstructions_) { + return; + } + const pixel = applyTransform(frameState.coordinateToPixelTransform, coordinate.slice()); + + const data = this.hitRenderTarget_.readPixel(pixel[0], pixel[1]); + const color = [ + data[0] / 255, + data[1] / 255, + data[2] / 255, + data[3] / 255 + ]; + const index = colorDecodeId(color); + const opacity = this.hitRenderInstructions_[index]; + const uid = Math.floor(opacity).toString(); + + const source = this.getLayer().getSource(); + const feature = source.getFeatureByUid(uid); + if (feature) { + return callback(feature, this.getLayer()); + } + } + + /** + * Render the hit detection data to the corresponding render target + * @param {import("../../PluggableMap.js").FrameState} frameState current frame state + */ + renderHitDetection(frameState) { + const width = frameState.size[0]; + const height = frameState.size[1]; + const size = [width, height]; + + this.hitRenderTarget_.setSize(size); + + this.helper.useProgram(this.hitProgram_); + this.helper.prepareDrawToRenderTarget(frameState, this.hitRenderTarget_, true); + + this.helper.makeProjectionTransform(frameState, this.currentTransform_); + multiplyTransform(this.currentTransform_, this.invertHitRenderTransform_); + + this.helper.bindBuffer(this.hitVerticesBuffer_); + this.helper.bindBuffer(this.indicesBuffer_); + + const stride = POINT_VERTEX_STRIDE; + const bytesPerFloat = Float32Array.BYTES_PER_ELEMENT; + this.helper.enableAttributeArray(DefaultAttrib.POSITION, 2, FLOAT, bytesPerFloat * stride, 0); + this.helper.enableAttributeArray(DefaultAttrib.OFFSETS, 2, FLOAT, bytesPerFloat * stride, bytesPerFloat * 2); + this.helper.enableAttributeArray(DefaultAttrib.TEX_COORD, 2, FLOAT, bytesPerFloat * stride, bytesPerFloat * 4); + this.helper.enableAttributeArray(DefaultAttrib.OPACITY, 1, FLOAT, bytesPerFloat * stride, bytesPerFloat * 6); + this.helper.enableAttributeArray(DefaultAttrib.ROTATE_WITH_VIEW, 1, FLOAT, bytesPerFloat * stride, bytesPerFloat * 7); + this.helper.enableAttributeArray(DefaultAttrib.COLOR, 4, FLOAT, bytesPerFloat * stride, bytesPerFloat * 8); + + const renderCount = this.indicesBuffer_.getArray() ? this.indicesBuffer_.getArray().length : 0; + this.helper.drawElements(0, renderCount); + } } export default WebGLPointsLayerRenderer; diff --git a/src/ol/source/Vector.js b/src/ol/source/Vector.js index 23aa25a30c..9f4a0f39f5 100644 --- a/src/ol/source/Vector.js +++ b/src/ol/source/Vector.js @@ -241,11 +241,11 @@ class VectorSource extends Source { this.idIndex_ = {}; /** - * A lookup of features without id (keyed by getUid(feature)). + * A lookup of features by uid (using getUid(feature)). * @private * @type {!Object>} */ - this.undefIdIndex_ = {}; + this.uidIndex_ = {}; /** * @private @@ -359,10 +359,11 @@ class VectorSource extends Source { } else { valid = false; } - } else { - assert(!(featureKey in this.undefIdIndex_), + } + if (valid) { + assert(!(featureKey in this.uidIndex_), 30); // The passed `feature` was already added to the source - this.undefIdIndex_[featureKey] = feature; + this.uidIndex_[featureKey] = feature; } return valid; } @@ -489,7 +490,7 @@ class VectorSource extends Source { if (!this.featuresCollection_) { this.featureChangeKeys_ = {}; this.idIndex_ = {}; - this.undefIdIndex_ = {}; + this.uidIndex_ = {}; } } else { if (this.featuresRtree_) { @@ -770,6 +771,18 @@ class VectorSource extends Source { } + /** + * Get a feature by its internal unique identifier (using `getUid`). + * + * @param {string} uid Feature identifier. + * @return {import("../Feature.js").default} The feature (or `null` if not found). + */ + getFeatureByUid(uid) { + const feature = this.uidIndex_[uid]; + return feature !== undefined ? feature : null; + } + + /** * Get the format associated with this source. * @@ -831,20 +844,13 @@ class VectorSource extends Source { const id = feature.getId(); if (id !== undefined) { const sid = id.toString(); - if (featureKey in this.undefIdIndex_) { - delete this.undefIdIndex_[featureKey]; + if (this.idIndex_[sid] !== feature) { + this.removeFromIdIndex_(feature); this.idIndex_[sid] = feature; - } else { - if (this.idIndex_[sid] !== feature) { - this.removeFromIdIndex_(feature); - this.idIndex_[sid] = feature; - } } } else { - if (!(featureKey in this.undefIdIndex_)) { - this.removeFromIdIndex_(feature); - this.undefIdIndex_[featureKey] = feature; - } + this.removeFromIdIndex_(feature); + this.uidIndex_[featureKey] = feature; } this.changed(); this.dispatchEvent(new VectorSourceEvent( @@ -862,7 +868,7 @@ class VectorSource extends Source { if (id !== undefined) { return id in this.idIndex_; } else { - return getUid(feature) in this.undefIdIndex_; + return getUid(feature) in this.uidIndex_; } } @@ -964,9 +970,8 @@ class VectorSource extends Source { const id = feature.getId(); if (id !== undefined) { delete this.idIndex_[id.toString()]; - } else { - delete this.undefIdIndex_[featureKey]; } + delete this.uidIndex_[featureKey]; this.dispatchEvent(new VectorSourceEvent( VectorEventType.REMOVEFEATURE, feature)); } diff --git a/src/ol/webgl/Helper.js b/src/ol/webgl/Helper.js index 1bc6e2d0db..e844e255ae 100644 --- a/src/ol/webgl/Helper.js +++ b/src/ol/webgl/Helper.js @@ -432,6 +432,30 @@ class WebGLHelper extends Disposable { gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.useProgram(this.currentProgram_); + this.applyFrameState(frameState); + this.applyUniforms(frameState); + } + + /** + * Clear the render target & bind it for future draw operations. + * This is similar to `prepareDraw`, only post processes will not be applied. + * Note: the whole viewport will be drawn to the render target, regardless of its size. + * @param {import("../PluggableMap.js").FrameState} frameState current frame state + * @param {import("./RenderTarget.js").default} renderTarget Render target to draw to + * @param {boolean} [opt_disableAlphaBlend] If true, no alpha blending will happen. + */ + prepareDrawToRenderTarget(frameState, renderTarget, opt_disableAlphaBlend) { + const gl = this.getGL(); + + gl.bindFramebuffer(gl.FRAMEBUFFER, renderTarget.getFramebuffer()); + gl.bindTexture(gl.TEXTURE_2D, renderTarget.getTexture()); + gl.clearColor(0.0, 0.0, 0.0, 0.0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, opt_disableAlphaBlend ? gl.ZERO : gl.ONE_MINUS_SRC_ALPHA); + + gl.useProgram(this.currentProgram_); this.applyFrameState(frameState); this.applyUniforms(frameState); } @@ -742,57 +766,36 @@ class WebGLHelper extends Disposable { // TODO: shutdown program /** - * TODO: these are not used and should be reworked - * @param {number=} opt_wrapS wrapS. - * @param {number=} opt_wrapT wrapT. - * @return {WebGLTexture} The texture. + * Will create or reuse a given webgl texture and apply the given size. If no image data + * specified, the texture will be empty, otherwise image data will be used and the `size` + * parameter will be ignored. + * Note: wrap parameters are set to clamp to edge, min filter is set to linear. + * @param {Array} size Expected size of the texture + * @param {ImageData|HTMLImageElement|HTMLCanvasElement} [opt_data] Image data/object to bind to the texture + * @param {WebGLTexture} [opt_texture] Existing texture to reuse + * @return {WebGLTexture} The generated texture + * @api */ - createTextureInternal(opt_wrapS, opt_wrapT) { + createTexture(size, opt_data, opt_texture) { const gl = this.getGL(); - const texture = gl.createTexture(); + const texture = opt_texture || gl.createTexture(); + + // set params & size + const level = 0; + const internalFormat = gl.RGBA; + const border = 0; + const format = gl.RGBA; + const type = gl.UNSIGNED_BYTE; gl.bindTexture(gl.TEXTURE_2D, texture); - gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + if (opt_data) { + gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, format, type, opt_data); + } else { + gl.texImage2D(gl.TEXTURE_2D, level, internalFormat, size[0], size[1], border, format, type, null); + } gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); - if (opt_wrapS !== undefined) { - gl.texParameteri( - gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, opt_wrapS); - } - if (opt_wrapT !== undefined) { - gl.texParameteri( - gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, opt_wrapT); - } - - return texture; - } - - /** - * TODO: these are not used and should be reworked - * @param {number} width Width. - * @param {number} height Height. - * @param {number=} opt_wrapS wrapS. - * @param {number=} opt_wrapT wrapT. - * @return {WebGLTexture} The texture. - */ - createEmptyTexture(width, height, opt_wrapS, opt_wrapT) { - const gl = this.getGL(); - const texture = this.createTextureInternal(opt_wrapS, opt_wrapT); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); - return texture; - } - - - /** - * TODO: these are not used and should be reworked - * @param {HTMLCanvasElement|HTMLImageElement|HTMLVideoElement} image Image. - * @param {number=} opt_wrapS wrapS. - * @param {number=} opt_wrapT wrapT. - * @return {WebGLTexture} The texture. - */ - createTexture(image, opt_wrapS, opt_wrapT) { - const gl = this.getGL(); - const texture = this.createTextureInternal(opt_wrapS, opt_wrapT); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); return texture; } } diff --git a/src/ol/webgl/RenderTarget.js b/src/ol/webgl/RenderTarget.js new file mode 100644 index 0000000000..4500495dc1 --- /dev/null +++ b/src/ol/webgl/RenderTarget.js @@ -0,0 +1,164 @@ +/** + * A wrapper class to simplify rendering to a texture instead of the final canvas + * @module ol/webgl/RenderTarget + */ +import {equals} from '../array.js'; + +// for pixel color reading +const tmpArray4 = new Uint8Array(4); + +/** + * @classdesc + * This class is a wrapper around the association of both a `WebGLTexture` and a `WebGLFramebuffer` instances, + * simplifying initialization and binding for rendering. + * @api + */ +class WebGLRenderTarget { + + /** + * @param {import("./Helper.js").default} helper WebGL helper; mandatory. + * @param {Array} [opt_size] Expected size of the render target texture; note: this can be changed later on. + */ + constructor(helper, opt_size) { + /** + * @private + * @type {import("./Helper.js").default} + */ + this.helper_ = helper; + const gl = helper.getGL(); + + /** + * @private + * @type {WebGLTexture} + */ + this.texture_ = gl.createTexture(); + + /** + * @private + * @type {WebGLFramebuffer} + */ + this.framebuffer_ = gl.createFramebuffer(); + + /** + * @type {Array} + * @private + */ + this.size_ = opt_size || [1, 1]; + + /** + * @type {Uint8Array} + * @private + */ + this.data_ = new Uint8Array(0); + + /** + * @type {boolean} + * @private + */ + this.dataCacheDirty_ = true; + + this.updateSize_(); + } + + /** + * Changes the size of the render target texture. Note: will do nothing if the size + * is already the same. + * @param {Array} size Expected size of the render target texture + * @api + */ + setSize(size) { + if (equals(size, this.size_)) { + return; + } + this.size_[0] = size[0]; + this.size_[1] = size[1]; + this.updateSize_(); + } + + /** + * Returns the size of the render target texture + * @return {Array} Size of the render target texture + * @api + */ + getSize() { + return this.size_; + } + + /** + * This will cause following calls to `#readAll` or `#readPixel` to download the content of the + * render target into memory, which is an expensive operation. + * This content will be kept in cache but should be cleared after each new render. + * @api + */ + clearCachedData() { + this.dataCacheDirty_ = true; + } + + /** + * Returns the full content of the frame buffer as a series of r, g, b, a components + * in the 0-255 range (unsigned byte). + * @return {Uint8Array} Integer array of color values + * @api + */ + readAll() { + if (this.dataCacheDirty_) { + const size = this.size_; + const gl = this.helper_.getGL(); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer_); + gl.readPixels(0, 0, size[0], size[1], gl.RGBA, gl.UNSIGNED_BYTE, this.data_); + this.dataCacheDirty_ = false; + } + return this.data_; + } + + /** + * Reads one pixel of the frame buffer as an array of r, g, b, a components + * in the 0-255 range (unsigned byte). + * @param {number} x Pixel coordinate + * @param {number} y Pixel coordinate + * @returns {Uint8Array} Integer array with one color value (4 components) + * @api + */ + readPixel(x, y) { + this.readAll(); + const index = Math.floor(x) + (this.size_[1] - Math.floor(y) - 1) * this.size_[0]; + tmpArray4[0] = this.data_[index * 4]; + tmpArray4[1] = this.data_[index * 4 + 1]; + tmpArray4[2] = this.data_[index * 4 + 2]; + tmpArray4[3] = this.data_[index * 4 + 3]; + return tmpArray4; + } + + /** + * @return {WebGLTexture} Texture to render to + */ + getTexture() { + return this.texture_; + } + + /** + * @return {WebGLFramebuffer} Frame buffer of the render target + */ + getFramebuffer() { + return this.framebuffer_; + } + + /** + * @private + */ + updateSize_() { + const size = this.size_; + const gl = this.helper_.getGL(); + + this.texture_ = this.helper_.createTexture(size, null, this.texture_); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer_); + gl.viewport(0, 0, size[0], size[1]); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this.texture_, 0); + + this.data_ = new Uint8Array(size[0] * size[1] * 4); + } +} + +export default WebGLRenderTarget; diff --git a/src/ol/webgl/Shader.js b/src/ol/webgl/Shader.js deleted file mode 100644 index ed9e0f0566..0000000000 --- a/src/ol/webgl/Shader.js +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @module ol/webgl/Shader - */ -import {abstract} from '../util.js'; - -/** - * @abstract - */ -class WebGLShader { - - /** - * @param {string} source Source. - */ - constructor(source) { - - /** - * @private - * @type {string} - */ - this.source_ = source; - - } - - /** - * @return {boolean} Is animated? - */ - isAnimated() { - return false; - } - - /** - * @abstract - * @return {number} Type. - */ - getType() { - return abstract(); - } - - /** - * @return {string} Source. - */ - getSource() { - return this.source_; - } -} - - -export default WebGLShader; diff --git a/test/spec/ol/renderer/webgl/layer.test.js b/test/spec/ol/renderer/webgl/layer.test.js index cb9f30f3c1..8bbd676850 100644 --- a/test/spec/ol/renderer/webgl/layer.test.js +++ b/test/spec/ol/renderer/webgl/layer.test.js @@ -1,5 +1,7 @@ import WebGLLayerRenderer, { - getBlankTexture, POINT_INSTRUCTIONS_COUNT, POINT_VERTEX_STRIDE, + colorDecodeId, + colorEncodeId, + getBlankImageData, POINT_INSTRUCTIONS_COUNT, POINT_VERTEX_STRIDE, writePointFeatureInstructions, writePointFeatureToBuffers } from '../../../../../src/ol/renderer/webgl/Layer.js'; import Layer from '../../../../../src/ol/layer/Layer.js'; @@ -237,9 +239,9 @@ describe('ol.renderer.webgl.Layer', function() { }); - describe('getBlankTexture', function() { + describe('getBlankImageData', function() { it('creates a 1x1 white texture', function() { - const texture = getBlankTexture(); + const texture = getBlankImageData(); expect(texture.height).to.eql(1); expect(texture.width).to.eql(1); expect(texture.data[0]).to.eql(255); @@ -249,4 +251,35 @@ describe('ol.renderer.webgl.Layer', function() { }); }); + describe('colorEncodeId and colorDecodeId', function() { + it('correctly encodes and decodes ids', function() { + expect(colorDecodeId(colorEncodeId(0))).to.eql(0); + expect(colorDecodeId(colorEncodeId(1))).to.eql(1); + expect(colorDecodeId(colorEncodeId(123))).to.eql(123); + expect(colorDecodeId(colorEncodeId(12345))).to.eql(12345); + expect(colorDecodeId(colorEncodeId(123456))).to.eql(123456); + expect(colorDecodeId(colorEncodeId(91612))).to.eql(91612); + expect(colorDecodeId(colorEncodeId(1234567890))).to.eql(1234567890); + }); + + it('correctly reuses array', function() { + const arr = []; + expect(colorEncodeId(123, arr)).to.be(arr); + }); + + it('is compatible with Uint8Array storage', function() { + const encoded = colorEncodeId(91612); + const typed = Uint8Array.of(encoded[0] * 255, encoded[1] * 255, + encoded[2] * 255, encoded[3] * 255); + const arr = [ + typed[0] / 255, + typed[1] / 255, + typed[2] / 255, + typed[3] / 255 + ]; + const decoded = colorDecodeId(arr); + expect(decoded).to.eql(91612); + }); + }); + }); diff --git a/test/spec/ol/renderer/webgl/pointslayer.test.js b/test/spec/ol/renderer/webgl/pointslayer.test.js index e08f32ed8f..7d67294960 100644 --- a/test/spec/ol/renderer/webgl/pointslayer.test.js +++ b/test/spec/ol/renderer/webgl/pointslayer.test.js @@ -6,6 +6,7 @@ import WebGLPointsLayerRenderer from '../../../../../src/ol/renderer/webgl/Point import {get as getProjection} from '../../../../../src/ol/proj.js'; import ViewHint from '../../../../../src/ol/ViewHint.js'; import {POINT_VERTEX_STRIDE, WebGLWorkerMessageType} from '../../../../../src/ol/renderer/webgl/Layer.js'; +import {create as createTransform, translate as translateTransform} from '../../../../../src/ol/transform.js'; describe('ol.renderer.webgl.PointsLayer', function() { @@ -146,4 +147,67 @@ describe('ol.renderer.webgl.PointsLayer', function() { }); }); + describe('#forEachFeatureAtCoordinate', function() { + let layer, renderer, feature; + + beforeEach(function() { + feature = new Feature(new Point([0, 0])); + layer = new VectorLayer({ + source: new VectorSource({ + features: [feature] + }) + }); + renderer = new WebGLPointsLayerRenderer(layer, { + sizeCallback: function() { + return 4; + } + }); + }); + + it('correctly hit detects a feature', function(done) { + const transform = translateTransform(createTransform(), 20, 20); + const projection = getProjection('EPSG:3857'); + const frameState = { + viewState: { + projection: projection, + resolution: 1, + rotation: 0, + center: [0, 0] + }, + layerStatesArray: [{}], + layerIndex: 0, + extent: [-20, -20, 20, 20], + size: [40, 40], + viewHints: [], + coordinateToPixelTransform: transform + }; + let found; + const cb = function(feature) { + found = feature; + }; + + renderer.prepareFrame(frameState); + renderer.worker_.addEventListener('message', function() { + if (!renderer.hitRenderInstructions_) { + return; + } + renderer.renderFrame(frameState); + + function checkHit(x, y, expected) { + found = null; + renderer.forEachFeatureAtCoordinate([x, y], frameState, 0, cb, null); + expect(found).to.be(expected ? feature : null); + } + + checkHit(0, 0, true); + checkHit(1, -2, true); + checkHit(-2, 1, true); + checkHit(2, 0, false); + checkHit(1, -3, false); + + done(); + }); + }); + }); + }); diff --git a/test/spec/ol/source/vector.test.js b/test/spec/ol/source/vector.test.js index d48d2962a3..9fdb56a983 100644 --- a/test/spec/ol/source/vector.test.js +++ b/test/spec/ol/source/vector.test.js @@ -10,6 +10,7 @@ import {bbox as bboxStrategy} from '../../../../src/ol/loadingstrategy.js'; import {get as getProjection, transformExtent, fromLonLat} from '../../../../src/ol/proj.js'; import VectorSource from '../../../../src/ol/source/Vector.js'; import GeoJSON from '../../../../src/ol/format/GeoJSON.js'; +import {getUid} from '../../../../src/ol/util.js'; describe('ol.source.Vector', function() { @@ -519,6 +520,59 @@ describe('ol.source.Vector', function() { }); + describe('#getFeatureByUid()', function() { + let source; + beforeEach(function() { + source = new VectorSource(); + }); + + it('returns a feature with an id', function() { + const feature = new Feature(); + feature.setId('abcd'); + source.addFeature(feature); + expect(source.getFeatureByUid(getUid(feature))).to.be(feature); + }); + + it('returns a feature without id', function() { + const feature = new Feature(); + source.addFeature(feature); + expect(source.getFeatureByUid(getUid(feature))).to.be(feature); + }); + + it('returns null when no feature is found', function() { + const feature = new Feature(); + feature.setId('abcd'); + source.addFeature(feature); + const wrongId = 'abcd'; + expect(source.getFeatureByUid(wrongId)).to.be(null); + }); + + it('returns null after removing feature', function() { + const feature = new Feature(); + feature.setId('abcd'); + source.addFeature(feature); + const uid = getUid(feature); + expect(source.getFeatureByUid(uid)).to.be(feature); + source.removeFeature(feature); + expect(source.getFeatureByUid(uid)).to.be(null); + }); + + it('returns null after clear', function() { + const feature = new Feature(); + feature.setId('abcd'); + source.addFeature(feature); + const uid = getUid(feature); + expect(source.getFeatureByUid(uid)).to.be(feature); + source.clear(); + expect(source.getFeatureByUid(uid)).to.be(null); + }); + + it('returns null when no features are present', function() { + expect(source.getFeatureByUid('abcd')).to.be(null); + }); + + }); + describe('#loadFeatures', function() { describe('with the "bbox" strategy', function() { diff --git a/test/spec/ol/webgl/helper.test.js b/test/spec/ol/webgl/helper.test.js index c474941c60..e53f4c2d0d 100644 --- a/test/spec/ol/webgl/helper.test.js +++ b/test/spec/ol/webgl/helper.test.js @@ -220,5 +220,74 @@ describe('ol.webgl.WebGLHelper', function() { }); }); + describe('#createTexture', function() { + let h; + beforeEach(function() { + h = new WebGLHelper(); + }); + + it('creates an empty texture from scratch', function() { + const width = 4; + const height = 4; + const t = h.createTexture([width, height]); + const gl = h.getGL(); + + const fb = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0); + const data = new Uint8Array(width * height * 4); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); + gl.deleteFramebuffer(fb); + + expect(data[0]).to.eql(0); + expect(data[1]).to.eql(0); + expect(data[2]).to.eql(0); + expect(data[3]).to.eql(0); + expect(data[4]).to.eql(0); + expect(data[5]).to.eql(0); + expect(data[6]).to.eql(0); + expect(data[7]).to.eql(0); + }); + + it('creates a texture from image data', function() { + const width = 4; + const height = 4; + const canvas = document.createElement('canvas'); + const image = canvas.getContext('2d').createImageData(width, height); + for (let i = 0; i < image.data.length; i += 4) { + image.data[i] = 100; + image.data[i + 1] = 150; + image.data[i + 2] = 200; + image.data[i + 3] = 250; + } + const t = h.createTexture([width, height], image); + const gl = h.getGL(); + + const fb = gl.createFramebuffer(); + gl.bindFramebuffer(gl.FRAMEBUFFER, fb); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0); + const data = new Uint8Array(width * height * 4); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); + gl.deleteFramebuffer(fb); + + expect(data[0]).to.eql(100); + expect(data[1]).to.eql(150); + expect(data[2]).to.eql(200); + expect(data[3]).to.eql(250); + expect(data[4]).to.eql(100); + expect(data[5]).to.eql(150); + expect(data[6]).to.eql(200); + expect(data[7]).to.eql(250); + }); + + it('reuses a given texture', function() { + const width = 4; + const height = 4; + const gl = h.getGL(); + const t1 = gl.createTexture(); + const t2 = h.createTexture([width, height], undefined, t1); + expect(t1).to.be(t2); + }); + }); }); }); diff --git a/test/spec/ol/webgl/rendertarget.test.js b/test/spec/ol/webgl/rendertarget.test.js new file mode 100644 index 0000000000..3468b16c26 --- /dev/null +++ b/test/spec/ol/webgl/rendertarget.test.js @@ -0,0 +1,128 @@ +import WebGLRenderTarget from '../../../../src/ol/webgl/RenderTarget.js'; +import WebGLHelper from '../../../../src/ol/webgl/Helper.js'; + + +describe('ol.webgl.RenderTarget', function() { + let helper, testImage_4x4; + + beforeEach(function() { + helper = new WebGLHelper(); + + const canvas = document.createElement('canvas'); + testImage_4x4 = canvas.getContext('2d').createImageData(4, 4); + for (let i = 0; i < testImage_4x4.data.length; i += 4) { + testImage_4x4.data[i] = 100 + i / 4; + testImage_4x4.data[i + 1] = 100 + i / 4; + testImage_4x4.data[i + 2] = 200 + i / 4; + testImage_4x4.data[i + 3] = 200 + i / 4; + } + }); + + describe('constructor', function() { + + it('creates a target of size 1x1', function() { + const rt = new WebGLRenderTarget(helper); + expect(rt.getSize()).to.eql([1, 1]); + }); + + it('creates a target of specified size', function() { + const rt = new WebGLRenderTarget(helper, [12, 34]); + expect(rt.getSize()).to.eql([12, 34]); + }); + + }); + + describe('#setSize', function() { + + it('updates the target size', function() { + const rt = new WebGLRenderTarget(helper, [12, 34]); + expect(rt.getSize()).to.eql([12, 34]); + rt.setSize([45, 67]); + expect(rt.getSize()).to.eql([45, 67]); + }); + + it('does nothing if the size has not changed', function() { + const rt = new WebGLRenderTarget(helper, [12, 34]); + const spy = sinon.spy(rt, 'updateSize_'); + rt.setSize([12, 34]); + expect(spy.called).to.be(false); + rt.setSize([12, 345]); + expect(spy.called).to.be(true); + }); + + }); + + describe('#readAll', function() { + + it('returns 1-pixel data with the default options', function() { + const rt = new WebGLRenderTarget(helper); + expect(rt.readAll().length).to.eql(4); + }); + + it('returns the content of the texture', function() { + const rt = new WebGLRenderTarget(helper, [4, 4]); + helper.createTexture([4, 4], testImage_4x4, rt.getTexture()); + const data = rt.readAll(); + + expect(data[0]).to.eql(100); + expect(data[1]).to.eql(100); + expect(data[2]).to.eql(200); + expect(data[3]).to.eql(200); + expect(data[4]).to.eql(101); + expect(data[5]).to.eql(101); + expect(data[6]).to.eql(201); + expect(data[7]).to.eql(201); + expect(data.length).to.eql(4 * 4 * 4); + }); + + it('does not call gl.readPixels again when #clearCachedData is not called', function() { + const rt = new WebGLRenderTarget(helper, [4, 4]); + helper.createTexture([4, 4], testImage_4x4, rt.getTexture()); + const spy = sinon.spy(rt.helper_.getGL(), 'readPixels'); + rt.readAll(); + expect(spy.callCount).to.eql(1); + rt.readAll(); + expect(spy.callCount).to.eql(1); + rt.clearCachedData(); + rt.readAll(); + expect(spy.callCount).to.eql(2); + }); + + }); + + describe('#readPixel', function() { + + it('returns the content of one pixel', function() { + const rt = new WebGLRenderTarget(helper, [4, 4]); + helper.createTexture([4, 4], testImage_4x4, rt.getTexture()); + + let data = rt.readPixel(0, 0); + expect(data[0]).to.eql(112); + expect(data[1]).to.eql(112); + expect(data[2]).to.eql(212); + expect(data[3]).to.eql(212); + + data = rt.readPixel(3, 3); + expect(data[0]).to.eql(103); + expect(data[1]).to.eql(103); + expect(data[2]).to.eql(203); + expect(data[3]).to.eql(203); + expect(data.length).to.eql(4); + }); + + it('does not call gl.readPixels again when #clearCachedData is not called', function() { + const rt = new WebGLRenderTarget(helper, [4, 4]); + helper.createTexture([4, 4], testImage_4x4, rt.getTexture()); + const spy = sinon.spy(rt.helper_.getGL(), 'readPixels'); + rt.readPixel(0, 0); + expect(spy.callCount).to.eql(1); + rt.readPixel(1, 1); + expect(spy.callCount).to.eql(1); + rt.clearCachedData(); + rt.readPixel(2, 2); + expect(spy.callCount).to.eql(2); + }); + + }); + +});