From 85ed293418ae14b9e3a59460ed498c896fdc7c67 Mon Sep 17 00:00:00 2001 From: David Manthey Date: Thu, 13 Jan 2022 10:02:19 -0500 Subject: [PATCH] perf: Add support for webgl pixelmaps. This also adds a 2D texture lookup table. This is a necessary antecedent to adding tiled pixelmaps. --- src/canvas/pixelmapFeature.js | 187 ++++++++++++++++++++++ src/pixelmapFeature.js | 280 ++++++--------------------------- src/quadFeature.js | 2 +- src/webgl/index.js | 2 + src/webgl/lookupTable2D.js | 122 ++++++++++++++ src/webgl/pixelmapFeature.frag | 47 ++++++ src/webgl/pixelmapFeature.js | 172 ++++++++++++++++++++ src/webgl/quadFeature.js | 33 +++- tests/cases/pixelmapFeature.js | 82 +++++++++- tests/cases/quadFeature.js | 14 ++ webpack.base.config.js | 8 +- 11 files changed, 713 insertions(+), 236 deletions(-) create mode 100644 src/webgl/lookupTable2D.js create mode 100644 src/webgl/pixelmapFeature.frag create mode 100644 src/webgl/pixelmapFeature.js diff --git a/src/canvas/pixelmapFeature.js b/src/canvas/pixelmapFeature.js index 7ac299c70c..00f3767876 100644 --- a/src/canvas/pixelmapFeature.js +++ b/src/canvas/pixelmapFeature.js @@ -1,6 +1,8 @@ var inherit = require('../inherit'); var registerFeature = require('../registry').registerFeature; var pixelmapFeature = require('../pixelmapFeature'); +var geo_event = require('../event'); +var util = require('../util'); /** * Create a new instance of class pixelmapFeature. @@ -22,6 +24,191 @@ var canvas_pixelmapFeature = function (arg) { var object = require('./object'); object.call(this); + var m_quadFeature, + s_exit = this._exit, + m_this = this; + + /** + * If the specified coordinates are in the rendered quad, use the basis + * information from the quad to determine the pixelmap index value so that it + * can be included in the `found` results. + * + * @param {geo.geoPosition} geo Coordinate. + * @param {string|geo.transform|null} [gcs] Input gcs. `undefined` to use + * the interface gcs, `null` to use the map gcs, or any other transform. + * @returns {geo.feature.searchResult} An object with a list of features and + * feature indices that are located at the specified point. + */ + this.pointSearch = function (geo, gcs) { + if (m_quadFeature && m_this.m_info) { + var result = m_quadFeature.pointSearch(geo, gcs); + if (result.index.length === 1 && result.extra && result.extra[result.index[0]].basis) { + var basis = result.extra[result.index[0]].basis, x, y, idx; + x = Math.floor(basis.x * m_this.m_info.width); + y = Math.floor(basis.y * m_this.m_info.height); + if (x >= 0 && x < m_this.m_info.width && + y >= 0 && y < m_this.m_info.height) { + idx = m_this.m_info.indices[y * m_this.m_info.width + x]; + result = { + index: [idx], + found: [m_this.data()[idx]] + }; + return result; + } + } + } + return {index: [], found: []}; + }; + + /** + * Compute information for this pixelmap image. It is wasteful to call this + * if the pixelmap has already been prepared (it is invalidated by a change + * in the image). + * + * @returns {geo.pixelmapFeature.info} + */ + this._preparePixelmap = function () { + var i, idx, pixelData; + + if (!util.isReadyImage(m_this.m_srcImage)) { + return; + } + m_this.m_info = { + width: m_this.m_srcImage.naturalWidth, + height: m_this.m_srcImage.naturalHeight, + canvas: document.createElement('canvas') + }; + + m_this.m_info.canvas.width = m_this.m_info.width; + m_this.m_info.canvas.height = m_this.m_info.height; + m_this.m_info.context = m_this.m_info.canvas.getContext('2d'); + + m_this.m_info.context.drawImage(m_this.m_srcImage, 0, 0); + m_this.m_info.imageData = m_this.m_info.context.getImageData( + 0, 0, m_this.m_info.canvas.width, m_this.m_info.canvas.height); + pixelData = m_this.m_info.imageData.data; + m_this.m_info.indices = new Array(pixelData.length / 4); + m_this.m_info.area = pixelData.length / 4; + + m_this.m_info.mappedColors = {}; + for (i = 0; i < pixelData.length; i += 4) { + idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); + m_this.m_info.indices[i / 4] = idx; + if (!m_this.m_info.mappedColors[idx]) { + m_this.m_info.mappedColors[idx] = {first: i / 4}; + } + m_this.m_info.mappedColors[idx].last = i / 4; + } + return m_this.m_info; + }; + + /** + * Given the loaded pixelmap image, create a canvas the size of the image. + * Compute a color for each distinct index and recolor the canvas based on + * these colors, then draw the resultant image as a quad. + * + * @fires geo.event.pixelmap.prepared + */ + this._computePixelmap = function () { + var data = m_this.data() || [], + colorFunc = m_this.style.get('color'), + i, idx, lastidx, color, pixelData, indices, mappedColors, + updateFirst, updateLast = -1, update, prepared; + + if (!m_this.m_info) { + if (!m_this._preparePixelmap()) { + return; + } + prepared = true; + } + mappedColors = m_this.m_info.mappedColors; + updateFirst = m_this.m_info.area; + for (idx in mappedColors) { + if (mappedColors.hasOwnProperty(idx)) { + color = colorFunc(data[idx], +idx) || {}; + color = [ + (color.r || 0) * 255, + (color.g || 0) * 255, + (color.b || 0) * 255, + color.a === undefined ? 255 : (color.a * 255) + ]; + mappedColors[idx].update = ( + !mappedColors[idx].color || + mappedColors[idx].color[0] !== color[0] || + mappedColors[idx].color[1] !== color[1] || + mappedColors[idx].color[2] !== color[2] || + mappedColors[idx].color[3] !== color[3]); + if (mappedColors[idx].update) { + mappedColors[idx].color = color; + updateFirst = Math.min(mappedColors[idx].first, updateFirst); + updateLast = Math.max(mappedColors[idx].last, updateLast); + } + } + } + /* If nothing was updated, we are done */ + if (updateFirst >= updateLast) { + return; + } + /* Update only the extent that has changed */ + pixelData = m_this.m_info.imageData.data; + indices = m_this.m_info.indices; + for (i = updateFirst; i <= updateLast; i += 1) { + idx = indices[i]; + if (idx !== lastidx) { + lastidx = idx; + color = mappedColors[idx].color; + update = mappedColors[idx].update; + } + if (update) { + pixelData[i * 4] = color[0]; + pixelData[i * 4 + 1] = color[1]; + pixelData[i * 4 + 2] = color[2]; + pixelData[i * 4 + 3] = color[3]; + } + } + /* Place the updated area into the canvas */ + m_this.m_info.context.putImageData( + m_this.m_info.imageData, 0, 0, 0, Math.floor(updateFirst / m_this.m_info.width), + m_this.m_info.width, Math.ceil((updateLast + 1) / m_this.m_info.width)); + + /* If we haven't made a quad feature, make one now. The quad feature needs + * to have the canvas capability. */ + if (!m_quadFeature) { + m_quadFeature = m_this.layer().createFeature('quad', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible(undefined, true) + }); + m_this.dependentFeatures([m_quadFeature]); + m_quadFeature.style({ + image: m_this.m_info.canvas, + position: m_this.style.get('position')}) + .data([{}]) + .draw(); + } + /* If we prepared the pixelmap and rendered it, send a prepared event */ + if (prepared) { + m_this.geoTrigger(geo_event.pixelmap.prepared, { + pixelmap: m_this + }); + } + }; + + /** + * Destroy. Deletes the associated quadFeature. + * + * @returns {this} + */ + this._exit = function () { + if (m_quadFeature && m_this.layer()) { + m_this.layer().deleteFeature(m_quadFeature); + m_quadFeature = null; + m_this.dependentFeatures([]); + } + s_exit(); + return m_this; + }; + this._init(arg); return this; }; diff --git a/src/pixelmapFeature.js b/src/pixelmapFeature.js index 1a95996e92..4730ea9e6e 100644 --- a/src/pixelmapFeature.js +++ b/src/pixelmapFeature.js @@ -1,7 +1,6 @@ var $ = require('jquery'); var inherit = require('./inherit'); var feature = require('./feature'); -var geo_event = require('./event'); var util = require('./util'); /** @@ -72,12 +71,8 @@ var pixelmapFeature = function (arg) { * @private */ var m_this = this, - m_quadFeature, - m_srcImage, - m_info, s_update = this._update, - s_init = this._init, - s_exit = this._exit; + s_init = this._init; this.featureType = 'pixelmap'; @@ -113,7 +108,7 @@ var pixelmapFeature = function (arg) { if (val === undefined) { return m_this.style('url'); } else if (val !== m_this.style('url')) { - m_srcImage = m_info = undefined; + m_this.m_srcImage = m_this.m_info = undefined; m_this.style('url', val); m_this.dataTime().modified(); m_this.modified(); @@ -121,28 +116,6 @@ var pixelmapFeature = function (arg) { return m_this; }; - /** - * Get the maximum index value from the pixelmap. This is a value present in - * the pixelmap. - * - * @returns {number} The maximum index value. - */ - this.maxIndex = function () { - if (m_info) { - /* This isn't just m_info.mappedColors.length - 1, since there - * may be more data than actual indices. */ - if (m_info.maxIndex === undefined) { - m_info.maxIndex = 0; - for (var idx in m_info.mappedColors) { - if (m_info.mappedColors.hasOwnProperty(idx)) { - m_info.maxIndex = Math.max(m_info.maxIndex, idx); - } - } - } - return m_info.maxIndex; - } - }; - /** * Get/Set color accessor. * @@ -162,35 +135,51 @@ var pixelmapFeature = function (arg) { }; /** - * If the specified coordinates are in the rendered quad, use the basis - * information from the quad to determine the pixelmap index value so that it - * can be included in the `found` results. + * Update. * - * @param {geo.geoPosition} geo Coordinate. - * @param {string|geo.transform|null} [gcs] Input gcs. `undefined` to use - * the interface gcs, `null` to use the map gcs, or any other transform. - * @returns {geo.feature.searchResult} An object with a list of features and - * feature indices that are located at the specified point. + * @returns {this} */ - this.pointSearch = function (geo, gcs) { - if (m_quadFeature && m_info) { - var result = m_quadFeature.pointSearch(geo, gcs); - if (result.index.length === 1 && result.extra && result.extra[result.index[0]].basis) { - var basis = result.extra[result.index[0]].basis, x, y, idx; - x = Math.floor(basis.x * m_info.width); - y = Math.floor(basis.y * m_info.height); - if (x >= 0 && x < m_info.width && - y >= 0 && y < m_info.height) { - idx = m_info.indices[y * m_info.width + x]; - result = { - index: [idx], - found: [m_this.data()[idx]] - }; - return result; + this._update = function () { + s_update.call(m_this); + if (m_this.buildTime().timestamp() <= m_this.dataTime().timestamp() || + m_this.updateTime().timestamp() < m_this.timestamp()) { + m_this._build(); + } + + m_this.updateTime().modified(); + return m_this; + }; + + /** + * Get the maximum index value from the pixelmap. This is a value present in + * the pixelmap. + * + * @returns {number} The maximum index value. + */ + this.maxIndex = function () { + if (m_this.m_info) { + /* This isn't just m_info.mappedColors.length - 1, since there + * may be more data than actual indices. */ + if (m_this.m_info.maxIndex === undefined) { + m_this.m_info.maxIndex = 0; + for (var idx in m_this.m_info.mappedColors) { + if (m_this.m_info.mappedColors.hasOwnProperty(idx)) { + m_this.m_info.maxIndex = Math.max(m_this.m_info.maxIndex, idx); + } } } + return m_this.m_info.maxIndex; } - return {index: [], found: []}; + }; + + /** + * Given the loaded pixelmap image, create a canvas the size of the image. + * Compute a color for each distinct index and recolor the canvas based on + * these colors, then draw the resultant image as a quad. + * + * @fires geo.event.pixelmap.prepared + */ + this._computePixelmap = function () { }; /** @@ -204,42 +193,42 @@ var pixelmapFeature = function (arg) { * checks if this feature is built. Setting the build time avoids calling * this a second time. */ m_this.buildTime().modified(); - if (!m_srcImage) { + if (!m_this.m_srcImage) { var src = m_this.style.get('url')(); if (util.isReadyImage(src)) { /* we have an already loaded image, so we can just use it. */ - m_srcImage = src; + m_this.m_srcImage = src; m_this._computePixelmap(); } else if (src) { var defer = $.Deferred(), prev_onload, prev_onerror; if (src instanceof Image) { /* we have an unloaded image. Hook to the load and error callbacks * so that when it is loaded we can use it. */ - m_srcImage = src; + m_this.m_srcImage = src; prev_onload = src.onload; prev_onerror = src.onerror; } else { /* we were given a url, so construct a new image */ - m_srcImage = new Image(); + m_this.m_srcImage = new Image(); // Only set the crossOrigin parameter if this is going across origins. if (src.indexOf(':') >= 0 && src.indexOf('/') === src.indexOf(':') + 1) { - m_srcImage.crossOrigin = m_this.style.get('crossDomain')() || 'anonymous'; + m_this.m_srcImage.crossOrigin = m_this.style.get('crossDomain')() || 'anonymous'; } } - m_srcImage.onload = function () { + m_this.m_srcImage.onload = function () { if (prev_onload) { prev_onload.apply(m_this, arguments); } /* Only use this image if our pixelmap hasn't changed since we * attached our handler */ if (m_this.style.get('url')() === src) { - m_info = undefined; + m_this.m_info = undefined; m_this._computePixelmap(); } defer.resolve(); }; - m_srcImage.onerror = function () { + m_this.m_srcImage.onerror = function () { if (prev_onerror) { prev_onerror.apply(m_this, arguments); } @@ -248,180 +237,15 @@ var pixelmapFeature = function (arg) { defer.promise(m_this); m_this.layer().addPromise(m_this); if (!(src instanceof Image)) { - m_srcImage.src = src; + m_this.m_srcImage.src = src; } } - } else if (m_info) { + } else if (m_this.m_info) { m_this._computePixelmap(); } return m_this; }; - /** - * Compute information for this pixelmap image. It is wasteful to call this - * if the pixelmap has already been prepared (it is invalidated by a change - * in the image). - * - * @returns {geo.pixelmapFeature.info} - */ - this._preparePixelmap = function () { - var i, idx, pixelData; - - if (!util.isReadyImage(m_srcImage)) { - return; - } - m_info = { - width: m_srcImage.naturalWidth, - height: m_srcImage.naturalHeight, - canvas: document.createElement('canvas') - }; - - m_info.canvas.width = m_info.width; - m_info.canvas.height = m_info.height; - m_info.context = m_info.canvas.getContext('2d'); - - m_info.context.drawImage(m_srcImage, 0, 0); - m_info.imageData = m_info.context.getImageData( - 0, 0, m_info.canvas.width, m_info.canvas.height); - pixelData = m_info.imageData.data; - m_info.indices = new Array(pixelData.length / 4); - m_info.area = pixelData.length / 4; - - m_info.mappedColors = {}; - for (i = 0; i < pixelData.length; i += 4) { - idx = pixelData[i] + (pixelData[i + 1] << 8) + (pixelData[i + 2] << 16); - m_info.indices[i / 4] = idx; - if (!m_info.mappedColors[idx]) { - m_info.mappedColors[idx] = {first: i / 4}; - } - m_info.mappedColors[idx].last = i / 4; - } - return m_info; - }; - - /** - * Given the loaded pixelmap image, create a canvas the size of the image. - * Compute a color for each distinct index and recolor the canvas based on - * these colors, then draw the resultant image as a quad. - * - * @fires geo.event.pixelmap.prepared - */ - this._computePixelmap = function () { - var data = m_this.data() || [], - colorFunc = m_this.style.get('color'), - i, idx, lastidx, color, pixelData, indices, mappedColors, - updateFirst, updateLast = -1, update, prepared; - - if (!m_info) { - if (!m_this._preparePixelmap()) { - return; - } - prepared = true; - } - mappedColors = m_info.mappedColors; - updateFirst = m_info.area; - for (idx in mappedColors) { - if (mappedColors.hasOwnProperty(idx)) { - color = colorFunc(data[idx], +idx) || {}; - color = [ - (color.r || 0) * 255, - (color.g || 0) * 255, - (color.b || 0) * 255, - color.a === undefined ? 255 : (color.a * 255) - ]; - mappedColors[idx].update = ( - !mappedColors[idx].color || - mappedColors[idx].color[0] !== color[0] || - mappedColors[idx].color[1] !== color[1] || - mappedColors[idx].color[2] !== color[2] || - mappedColors[idx].color[3] !== color[3]); - if (mappedColors[idx].update) { - mappedColors[idx].color = color; - updateFirst = Math.min(mappedColors[idx].first, updateFirst); - updateLast = Math.max(mappedColors[idx].last, updateLast); - } - } - } - /* If nothing was updated, we are done */ - if (updateFirst >= updateLast) { - return; - } - /* Update only the extent that has changed */ - pixelData = m_info.imageData.data; - indices = m_info.indices; - for (i = updateFirst; i <= updateLast; i += 1) { - idx = indices[i]; - if (idx !== lastidx) { - lastidx = idx; - color = mappedColors[idx].color; - update = mappedColors[idx].update; - } - if (update) { - pixelData[i * 4] = color[0]; - pixelData[i * 4 + 1] = color[1]; - pixelData[i * 4 + 2] = color[2]; - pixelData[i * 4 + 3] = color[3]; - } - } - /* Place the updated area into the canvas */ - m_info.context.putImageData( - m_info.imageData, 0, 0, 0, Math.floor(updateFirst / m_info.width), - m_info.width, Math.ceil((updateLast + 1) / m_info.width)); - - /* If we haven't made a quad feature, make one now. The quad feature needs - * to have the canvas capability. */ - if (!m_quadFeature) { - m_quadFeature = m_this.layer().createFeature('quad', { - selectionAPI: false, - gcs: m_this.gcs(), - visible: m_this.visible(undefined, true) - }); - m_this.dependentFeatures([m_quadFeature]); - m_quadFeature.style({ - image: m_info.canvas, - position: m_this.style.get('position')}) - .data([{}]) - .draw(); - } - /* If we prepared the pixelmap and rendered it, send a prepared event */ - if (prepared) { - m_this.geoTrigger(geo_event.pixelmap.prepared, { - pixelmap: m_this - }); - } - }; - - /** - * Update. - * - * @returns {this} - */ - this._update = function () { - s_update.call(m_this); - if (m_this.buildTime().timestamp() <= m_this.dataTime().timestamp() || - m_this.updateTime().timestamp() < m_this.timestamp()) { - m_this._build(); - } - - m_this.updateTime().modified(); - return m_this; - }; - - /** - * Destroy. Deletes the associated quadFeature. - * - * @returns {this} - */ - this._exit = function () { - if (m_quadFeature && m_this.layer()) { - m_this.layer().deleteFeature(m_quadFeature); - m_quadFeature = null; - m_this.dependentFeatures([]); - } - s_exit(); - return m_this; - }; - /** * Initialize. * diff --git a/src/quadFeature.js b/src/quadFeature.js index b7d90f14f5..9b357226c9 100644 --- a/src/quadFeature.js +++ b/src/quadFeature.js @@ -232,7 +232,7 @@ var quadFeature = function (arg) { coordbasis.y = 1 - coordbasis.y; } if (coordbasis) { - extra[quad.idx] = {basis: coordbasis}; + extra[quad.idx] = {basis: coordbasis, _quad: quad}; } } }); diff --git a/src/webgl/index.js b/src/webgl/index.js index 65d6a99412..1d7ebd5c70 100644 --- a/src/webgl/index.js +++ b/src/webgl/index.js @@ -8,8 +8,10 @@ module.exports = { isolineFeature: require('./isolineFeature'), layer: require('./layer'), lineFeature: require('./lineFeature'), + lookupTable2D: require('./lookupTable2D'), markerFeature: require('./markerFeature'), meshColored: require('./meshColored'), + pixelmapFeature: require('./pixelmapFeature'), pointFeature: require('./pointFeature'), polygonFeature: require('./polygonFeature'), quadFeature: require('./quadFeature'), diff --git a/src/webgl/lookupTable2D.js b/src/webgl/lookupTable2D.js new file mode 100644 index 0000000000..bd0f3d6697 --- /dev/null +++ b/src/webgl/lookupTable2D.js @@ -0,0 +1,122 @@ +var inherit = require('../inherit'); +var timestamp = require('../timestamp'); +var vgl = require('vgl'); + +/** + * Switch to a specific texture unit. + * + * @param {vgl.renderState} renderState An object that contains the context + * used for drawing. + * @param {number} textureUnit The number of the texture unit [0-15]. + */ +function activateTextureUnit(renderState, textureUnit) { + if (textureUnit >= 0 && textureUnit <= 31) { + renderState.m_context.activeTexture(vgl.GL.TEXTURE0 + textureUnit); + } else { + throw Error('[error] Texture unit ' + textureUnit + ' is not supported'); + } +} + +/** + * Create a new instance of class webgl_lookupTable2D. + * + * @class + * @alias geo.webgl.lookupTable2D + * @param {object} arg Options object. + * @param {number} [arg.maxWidth] Maximum width to use for the texture. If the + * number of colors set is less than this, the texture is 1D. If greater, it + * will be a rectangle of maxWidth x whatever height is necessary. + * @param {number[]} [arg.colorTable] Initial color table for the texture. + * This is of the form RGBARGBA... where each value is an integer on the + * scale [0,255]. + * @extends vgl.texture + * @returns {geo.webgl.lookupTable2D} + */ +var webgl_lookupTable2D = function (arg) { + 'use strict'; + + if (!(this instanceof webgl_lookupTable2D)) { + return new webgl_lookupTable2D(arg); + } + arg = arg || {}; + vgl.texture.call(this); + + var m_setupTimestamp = timestamp(), + m_maxWidth = arg.maxWidth || 4096, + m_colorTable = new Uint8Array([0, 0, 0, 0]), + m_colorTableOrig, + m_this = this; + + /** + * Create lookup table, initialize parameters, and bind data to it. + * + * @param {vgl.renderState} renderState An object that contains the context + * used for drawing. + */ + this.setup = function (renderState) { + activateTextureUnit(renderState, m_this.textureUnit()); + + renderState.m_context.deleteTexture(m_this.m_textureHandle); + m_this.m_textureHandle = renderState.m_context.createTexture(); + renderState.m_context.bindTexture(vgl.GL.TEXTURE_2D, m_this.m_textureHandle); + renderState.m_context.texParameteri(vgl.GL.TEXTURE_2D, vgl.GL.TEXTURE_MIN_FILTER, vgl.GL.NEAREST); + renderState.m_context.texParameteri(vgl.GL.TEXTURE_2D, vgl.GL.TEXTURE_MAG_FILTER, vgl.GL.NEAREST); + renderState.m_context.texParameteri(vgl.GL.TEXTURE_2D, vgl.GL.TEXTURE_WRAP_S, vgl.GL.CLAMP_TO_EDGE); + renderState.m_context.texParameteri(vgl.GL.TEXTURE_2D, vgl.GL.TEXTURE_WRAP_T, vgl.GL.CLAMP_TO_EDGE); + renderState.m_context.pixelStorei(vgl.GL.UNPACK_ALIGNMENT, 1); + renderState.m_context.pixelStorei(vgl.GL.UNPACK_FLIP_Y_WEBGL, true); + + renderState.m_context.texImage2D( + vgl.GL.TEXTURE_2D, 0, vgl.GL.RGBA, m_this.width, m_this.height, 0, + vgl.GL.RGBA, vgl.GL.UNSIGNED_BYTE, m_colorTable); + + renderState.m_context.bindTexture(vgl.GL.TEXTURE_2D, null); + m_setupTimestamp.modified(); + }; + + /** + * Get/set color table. + * + * @param {number[]} [val] An array of RGBARGBA... integers on a scale + * of [0, 255]. `undefined` to get the current value. + * @returns {number[]|this} + */ + this.colorTable = function (val) { + if (val === undefined) { + return m_colorTableOrig; + } + m_colorTableOrig = val; + if (val.length < 4) { + val = [0, 0, 0, 0]; + } + m_this.width = Math.min(m_maxWidth, val.length / 4); + m_this.height = Math.ceil(val.length / 4 / m_maxWidth); + if (!(val instanceof Uint8Array) || val.length !== m_this.width * m_this.height * 4) { + if (val.length < m_this.width * m_this.height * 4) { + val = val.concat(new Array(m_this.width * m_this.height * 4 - val.length).fill(0)); + } + m_colorTable = new Uint8Array(val); + } else { + m_colorTable = val; + } + m_this.modified(); + return m_this; + }; + + /** + * Get maxWidth value. + * + * @returns {number} The maxWidth of the texture used. + */ + this.maxWidth = function () { + return m_maxWidth; + }; + + this.colorTable(arg.colorTable || []); + + return this; +}; + +inherit(webgl_lookupTable2D, vgl.texture); + +module.exports = webgl_lookupTable2D; diff --git a/src/webgl/pixelmapFeature.frag b/src/webgl/pixelmapFeature.frag new file mode 100644 index 0000000000..df26ce0a74 --- /dev/null +++ b/src/webgl/pixelmapFeature.frag @@ -0,0 +1,47 @@ +/* pixelmapFeature fragment shader */ + +varying highp vec2 iTextureCoord; +uniform sampler2D sampler2d; +uniform sampler2D lutSampler; +uniform int lutWidth; +uniform int lutHeight; +uniform mediump float opacity; +uniform highp vec2 crop; + +void main(void) { + if ((crop.s < 1.0 && iTextureCoord.s > crop.s) || (crop.t < 1.0 && 1.0 - iTextureCoord.t > crop.t)) { + discard; + } + // to add anti-aliasing, we would need to know the current pixel size + // (probably computed in the vertex shader) and then sample the base image at + // multiple points, then average the output color. + highp vec4 lutValue = texture2D(sampler2d, iTextureCoord); + highp vec2 lutCoord; + lutCoord.s = ( + mod( + // add 0.5 to handle float imprecision + floor(lutValue.r * 255.0 + 0.5) + + floor(lutValue.g * 255.0 + 0.5) * 256.0, + float(lutWidth) + // center in pixel + ) + 0.5) / float(lutWidth); + // Our image is top-down, so invert the coordinate + lutCoord.t = 1.0 - ( + floor( + ( + // add 0.5 to handle float imprecision + floor(lutValue.r * 255.0 + 0.5) + + floor(lutValue.g * 255.0 + 0.5) * 256.0 + + floor(lutValue.b * 255.0 + 0.5) * 256.0 * 256.0 + // We may want an option to use the alpha channel to allow more indices + ) / float(lutWidth) + // center in pixel + ) + 0.5) / float(lutHeight); + if (lutCoord.t < 0.0) { + discard; + } + mediump vec4 color = texture2D(lutSampler, lutCoord); + + color.a *= opacity; + gl_FragColor = color; +} diff --git a/src/webgl/pixelmapFeature.js b/src/webgl/pixelmapFeature.js new file mode 100644 index 0000000000..b7cc574265 --- /dev/null +++ b/src/webgl/pixelmapFeature.js @@ -0,0 +1,172 @@ +var inherit = require('../inherit'); +var registerFeature = require('../registry').registerFeature; +var pixelmapFeature = require('../pixelmapFeature'); +var lookupTable2D = require('./lookupTable2D'); +var util = require('../util'); + +/** + * Create a new instance of class webgl.pixelmapFeature. + * + * @class + * @alias geo.webgl.pixelmapFeature + * @extends geo.pixelmapFeature + * @param {geo.pixelmapFeature.spec} arg + * @returns {geo.webgl.pixelmapFeature} + */ +var webgl_pixelmapFeature = function (arg) { + 'use strict'; + + if (!(this instanceof webgl_pixelmapFeature)) { + return new webgl_pixelmapFeature(arg); + } + pixelmapFeature.call(this, arg); + + var object = require('./object'); + object.call(this); + + const vgl = require('vgl'); + const fragmentShader = require('./pixelmapFeature.frag'); + + var m_quadFeature, + s_exit = this._exit, + m_lookupTable, + m_this = this; + + /** + * If the specified coordinates are in the rendered quad, use the basis + * information from the quad to determine the pixelmap index value so that it + * can be included in the `found` results. + * + * @param {geo.geoPosition} geo Coordinate. + * @param {string|geo.transform|null} [gcs] Input gcs. `undefined` to use + * the interface gcs, `null` to use the map gcs, or any other transform. + * @returns {geo.feature.searchResult} An object with a list of features and + * feature indices that are located at the specified point. + */ + this.pointSearch = function (geo, gcs) { + if (m_quadFeature && m_this.m_info) { + let result = m_quadFeature.pointSearch(geo, gcs); + if (result.index.length === 1 && + result.extra && result.extra[result.index[0]].basis && + result.extra[result.index[0]]._quad && + result.extra[result.index[0]]._quad.image) { + const img = result.extra[result.index[0]]._quad.image; + const basis = result.extra[result.index[0]].basis; + const x = Math.floor(basis.x * img.width); + const y = Math.floor(basis.y * img.height); + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d'); + context.drawImage(img, x, y, 1, 1, 0, 0, 1, 1); + const pixel = context.getImageData(0, 0, 1, 1).data; + const idx = pixel[0] + pixel[1] * 256 + pixel[2] * 256 * 256; + result = { + index: [idx], + found: [m_this.data()[idx]] + }; + return result; + } + } + return {index: [], found: []}; + }; + + /** + * Given the loaded pixelmap image, create a texture for the colors and a + * quad that will use it. + */ + this._computePixelmap = function () { + var data = m_this.data() || [], + colorFunc = m_this.style.get('color'); + + if (!m_lookupTable) { + m_lookupTable = lookupTable2D(); + m_lookupTable.setTextureUnit(1); + } + let clrLen = Math.max(1, data.length); + const maxWidth = m_lookupTable.maxWidth(); + if (clrLen > maxWidth && clrLen % maxWidth) { + clrLen += maxWidth - (clrLen % maxWidth); + } + const colors = new Uint8Array(clrLen * 4); + data.forEach((d, i) => { + const color = util.convertColor(colorFunc.call(m_this, d, i)); + colors[i * 4] = color.r * 255; + colors[i * 4 + 1] = color.g * 255; + colors[i * 4 + 2] = color.b * 255; + colors[i * 4 + 3] = color.a === undefined ? 255 : (color.a * 255); + }); + m_this.m_info = {colors: colors}; + // check if colors haven't changed + var oldcolors = m_lookupTable.colorTable(); + if (oldcolors && oldcolors.length === colors.length) { + let idx = 0; + for (; idx < colors.length; idx += 1) { + if (colors[idx] !== oldcolors[idx]) { + break; + } + } + if (idx === colors.length) { + return; + } + } + m_lookupTable.colorTable(colors); + /* If we haven't made a quad feature, make one now */ + if (!m_quadFeature) { + m_quadFeature = m_this.layer().createFeature('quad', { + selectionAPI: false, + gcs: m_this.gcs(), + visible: m_this.visible(undefined, true) + }); + m_this.dependentFeatures([m_quadFeature]); + m_quadFeature.setShader('image_fragment', fragmentShader); + m_quadFeature._hookBuild = (prog) => { + const lutSampler = new vgl.uniform(vgl.GL.INT, 'lutSampler'); + lutSampler.set(m_lookupTable.textureUnit()); + prog.addUniform(lutSampler); + const lutWidth = new vgl.uniform(vgl.GL.INT, 'lutWidth'); + lutWidth.set(m_lookupTable.width); + prog.addUniform(lutWidth); + const lutHeight = new vgl.uniform(vgl.GL.INT, 'lutHeight'); + lutHeight.set(m_lookupTable.height); + prog.addUniform(lutHeight); + }; + m_quadFeature._hookRenderImageQuads = (renderState, quads) => { + quads.forEach((quad) => { + if (quad.image && quad.texture && !quad.texture.nearestPixel()) { + quad.texture.setNearestPixel(true); + } + }); + m_lookupTable.bind(renderState, quads); + }; + m_quadFeature.style({ + image: m_this.m_srcImage, + position: m_this.style.get('position')}) + .data([{}]) + .draw(); + } + }; + + /** + * Destroy. Deletes the associated quadFeature. + * + * @returns {this} + */ + this._exit = function () { + if (m_quadFeature && m_this.layer()) { + m_this.layer().deleteFeature(m_quadFeature); + m_quadFeature = null; + m_this.dependentFeatures([]); + } + s_exit(); + return m_this; + }; + + this._init(arg); + return this; +}; + +inherit(webgl_pixelmapFeature, pixelmapFeature); + +// Now register it +registerFeature('webgl', 'pixelmap', webgl_pixelmapFeature); +module.exports = webgl_pixelmapFeature; diff --git a/src/webgl/quadFeature.js b/src/webgl/quadFeature.js index ef3392ec79..a571ffde04 100644 --- a/src/webgl/quadFeature.js +++ b/src/webgl/quadFeature.js @@ -158,7 +158,7 @@ var webgl_quadFeature = function (arg) { * Build this feature. */ this._build = function () { - var mapper, mat, prog, srctex, unicrop, unicropsource, geom, context; + var mapper, mat, prog, srctex, unicrop, unicropsource, geom, context, sampler2d; if (!m_this.position()) { return; @@ -183,6 +183,10 @@ var webgl_quadFeature = function (arg) { prog.addUniform(new vgl.projectionUniform('projectionMatrix')); prog.addUniform(new vgl.floatUniform('opacity', 1.0)); prog.addUniform(new vgl.floatUniform('zOffset', 0.0)); + /* Use texture unit 0 */ + sampler2d = new vgl.uniform(vgl.GL.INT, 'sampler2d'); + sampler2d.set(0); + prog.addUniform(sampler2d); context = m_this.renderer()._glContext(); unicrop = new vgl.uniform(context.FLOAT_VEC2, 'crop'); unicrop.set([1.0, 1.0]); @@ -194,6 +198,9 @@ var webgl_quadFeature = function (arg) { context.VERTEX_SHADER, context, vertexShaderImage)); prog.addShader(vgl.getCachedShader( context.FRAGMENT_SHADER, context, fragmentShaderImage)); + if (m_this._hookBuild) { + m_this._hookBuild(prog); + } mat.addAttribute(prog); mat.addAttribute(new vgl.blend()); /* This is similar to vgl.planeSource */ @@ -355,6 +362,9 @@ var webgl_quadFeature = function (arg) { cropsrc = {x0: 0, y0: 0, x1: 1, y1: 1}, quadcropsrc, w, h, quadw, quadh; + if (m_this._hookRenderImageQuads) { + m_this._hookRenderImageQuads(renderState, m_quads.imgQuads); + } context.bindBuffer(context.ARRAY_BUFFER, m_glBuffers.imgQuadsPosition); $.each(m_quads.imgQuads, function (idx, quad) { if (!quad.image) { @@ -450,6 +460,27 @@ var webgl_quadFeature = function (arg) { m_this.modified(); }; + /** + * Set the image or color vertex or fragment shader. + * + * @param {string} shaderType One of `image_vertex`, `image_fragment`, + * `color_vertex`, or `color_fragment`. + * @param {string} shaderCode The shader program. + * @returns {this} The class instance on success, undefined in an unknown + * shaderType was specified. + */ + this.setShader = function (shaderType, shaderCode) { + switch (shaderType) { + case 'image_vertex': vertexShaderImage = shaderCode; break; + case 'image_fragment': fragmentShaderImage = shaderCode; break; + case 'color_vertex': vertexShaderColor = shaderCode; break; + case 'color_fragment': fragmentShaderColor = shaderCode; break; + default: + return; + } + return m_this; + }; + /** * Destroy. */ diff --git a/tests/cases/pixelmapFeature.js b/tests/cases/pixelmapFeature.js index ac4d443bc7..b79730bc08 100644 --- a/tests/cases/pixelmapFeature.js +++ b/tests/cases/pixelmapFeature.js @@ -1,12 +1,16 @@ -// Test geo.pixelmapFeature and geo.canvas.pixelmapFeature +// Test geo.pixelmapFeature, geo.canvas.pixelmapFeature, geo.webgl.pixelmapFeature /* globals Image */ +var $ = require('jquery'); var geo = require('../test-utils').geo; var createMap = require('../test-utils').createMap; -var $ = require('jquery'); +var destroyMap = require('../test-utils').destroyMap; var waitForIt = require('../test-utils').waitForIt; var logCanvas2D = require('../test-utils').logCanvas2D; +var mockWebglRenderer = geo.util.mockWebglRenderer; +var restoreWebglRenderer = geo.util.restoreWebglRenderer; +var vgl = require('vgl'); describe('geo.pixelmapFeature', function () { 'use strict'; @@ -73,7 +77,7 @@ describe('geo.pixelmapFeature', function () { var map, layer, pixelmap; map = createMap(); layer = map.createLayer('feature', {renderer: 'canvas'}); - pixelmap = geo.pixelmapFeature({layer: layer}); + pixelmap = geo.canvas.pixelmapFeature({layer: layer}); pixelmap._init({ position: position, url: testImage @@ -87,7 +91,7 @@ describe('geo.pixelmapFeature', function () { var map, layer, pixelmap; map = createMap(); layer = map.createLayer('feature', {renderer: 'canvas'}); - pixelmap = geo.pixelmapFeature({layer: layer}); + pixelmap = geo.canvas.pixelmapFeature({layer: layer}); pixelmap._init({ position: position, url: testImage @@ -111,7 +115,7 @@ describe('geo.pixelmapFeature', function () { map = createMap(); layer = map.createLayer('feature', {renderer: 'canvas'}); - pixelmap = geo.pixelmapFeature({layer: layer}); + pixelmap = geo.canvas.pixelmapFeature({layer: layer}); pixelmap._init({ position: position, url: testImage @@ -332,4 +336,72 @@ describe('geo.pixelmapFeature', function () { window._canvasLog.counts.drawImage >= (counts.drawImage || 0) + 1; }); }); + + /* This is a basic integration test of geo.webgl.pixelmapFeature. */ + describe('geo.webgl.pixelmapFeature', function () { + var map, layer, pixelmap, buildTime, glCounts; + it('basic usage', function () { + mockWebglRenderer(); + map = createMap(); + layer = map.createLayer('feature', {renderer: 'webgl'}); + pixelmap = layer.createFeature('pixelmap', { + position: position, + url: testImage + }); + /* Trigger rerendering */ + pixelmap.data(['a', 'b', 'c', 'd', 'e', 'f']); + buildTime = pixelmap.buildTime().timestamp(); + glCounts = $.extend({}, vgl.mockCounts()); + map.draw(); + expect(buildTime).not.toEqual(pixelmap.buildTime().timestamp()); + }); + waitForIt('next render webgl A', function () { + return vgl.mockCounts().createProgram >= (glCounts.createProgram || 0) + 1; + }); + it('Minimal update', function () { + pixelmap.modified(); + glCounts = $.extend({}, vgl.mockCounts()); + pixelmap.draw(); + }); + waitForIt('next render webgl B', function () { + return vgl.mockCounts().drawArrays >= (glCounts.drawArrays || 0) + 1; + }); + it('Heavier update', function () { + var colorFunc = function (d, i) { + return i & 1 ? 'red' : 'blue'; + }; + pixelmap.color(colorFunc); + glCounts = $.extend({}, vgl.mockCounts()); + pixelmap.draw(); + }); + waitForIt('next render webgl C', function () { + return vgl.mockCounts().drawArrays >= (glCounts.drawArrays || 0) + 1; + }); + it('pointSearch', function () { + var pt = pixelmap.pointSearch({x: -135, y: 65}); + expect(pt).toEqual({index: [1], found: ['b']}); + pt = pixelmap.pointSearch({x: -145, y: 65}); + expect(pt).toEqual({index: [], found: []}); + pt = pixelmap.pointSearch({x: -65, y: 15}); + expect(pt).toEqual({index: [2], found: ['c']}); + }); + it('Change data', function () { + glCounts = $.extend({}, vgl.mockCounts()); + pixelmap.data(new Array(5000).fill(0)).draw(); + }); + waitForIt('next render webgl D', function () { + return vgl.mockCounts().drawArrays >= (glCounts.drawArrays || 0) + 1; + }); + it('Data without change', function () { + glCounts = $.extend({}, vgl.mockCounts()); + pixelmap.data(new Array(5000).fill(0)).draw(); + }); + waitForIt('next render webgl E', function () { + return vgl.mockCounts().drawArrays >= (glCounts.drawArrays || 0) + 1; + }); + it('_exit', function () { + destroyMap(); + restoreWebglRenderer(); + }); + }); }); diff --git a/tests/cases/quadFeature.js b/tests/cases/quadFeature.js index 8a939255be..a48fa2e979 100644 --- a/tests/cases/quadFeature.js +++ b/tests/cases/quadFeature.js @@ -574,6 +574,20 @@ describe('geo.quadFeature', function () { destroyMap(); restoreWebglRenderer(); }); + + it('setShader', function () { + mockWebglRenderer(); + var map, layer, quad; + map = createMap(); + layer = map.createLayer('feature', {renderer: 'webgl'}); + quad = geo.quadFeature.create(layer); + ['image_vertex', 'image_fragment', 'color_vertex', 'color_fragment'].forEach((shaderType) => { + expect(quad.setShader(shaderType, 'shader placeholder')).toBe(quad); + }); + expect(quad.setShader('unknown', 'shader placeholder')).toBe(undefined); + destroyMap(); + restoreWebglRenderer(); + }); }); /* This is a basic integration test of geo.canvas.quadFeature. */ diff --git a/webpack.base.config.js b/webpack.base.config.js index 95972662b6..0b4787b8ac 100644 --- a/webpack.base.config.js +++ b/webpack.base.config.js @@ -60,6 +60,7 @@ module.exports = { use: [{ loader: 'babel-loader', options: { + cacheDirectory: true, presets: [['@babel/preset-env', { targets: 'defaults, PhantomJS 2.1' }]] @@ -72,7 +73,12 @@ module.exports = { path.resolve('examples'), path.resolve('tutorials') ], - use: ['babel-loader'] + use: [{ + loader: 'babel-loader', + options: { + cacheDirectory: true + } + }] }, { test: /\.styl$/, use: [