From 0c486c522acb3915307e126a84865e7c49bba0b0 Mon Sep 17 00:00:00 2001 From: Tim Schaub Date: Tue, 21 Jul 2015 17:10:44 -0600 Subject: [PATCH] Allow UI thread to be used Where workers are not available, or if operations are trivial to run, the main UI thread can be used instead. This also adds tests that run in real browsers. --- externs/olx.js | 22 ++ package.json | 2 +- src/ol/source/rastersource.js | 87 ++++++- test/spec/ol/source/rastersource.test.js | 307 +++++++++++++++++++++++ 4 files changed, 404 insertions(+), 14 deletions(-) create mode 100644 test/spec/ol/source/rastersource.test.js diff --git a/externs/olx.js b/externs/olx.js index 467d2eb6ea..51f8a54c38 100644 --- a/externs/olx.js +++ b/externs/olx.js @@ -4501,6 +4501,8 @@ olx.source.ImageVectorOptions.prototype.style; /** * @typedef {{sources: Array., * operations: (Array.|undefined), + * lib: (Object|undefined), + * threads: (number|undefined), * operationType: (ol.raster.OperationType|undefined)}} * @api */ @@ -4524,6 +4526,26 @@ olx.source.RasterOptions.prototype.sources; olx.source.RasterOptions.prototype.operations; +/** + * Functions that will be made available to operations run in a worker. + * @type {Object|undefined} + * @api + */ +olx.source.RasterOptions.prototype.lib; + + +/** + * By default, operations will be run in a single worker thread. To avoid using + * workers altogether, set `threads: 0`. For pixel operations, operations can + * be run in multiple worker threads. Note that there some additional overhead + * in transferring data to multiple workers, and that depending on the user's + * system, it may not be possible to parallelize the work. + * @type {number|undefined} + * @api + */ +olx.source.RasterOptions.prototype.threads; + + /** * Operation type. Supported values are `'pixel'` and `'image'`. By default, * `'pixel'` operations are assumed, and operations will be called with an diff --git a/package.json b/package.json index 07f3e6da85..a0dafaeda5 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "metalsmith": "1.6.0", "metalsmith-templates": "0.7.0", "nomnom": "1.8.0", - "pixelworks": "^0.10.1", + "pixelworks": "^0.11.0", "rbush": "1.3.5", "temp": "0.8.1", "walk": "2.3.4", diff --git a/src/ol/source/rastersource.js b/src/ol/source/rastersource.js index 02ed2c6879..46b4d13502 100644 --- a/src/ol/source/rastersource.js +++ b/src/ol/source/rastersource.js @@ -3,7 +3,9 @@ goog.provide('ol.source.RasterEvent'); goog.provide('ol.source.RasterEventType'); goog.require('goog.asserts'); +goog.require('goog.events'); goog.require('goog.events.Event'); +goog.require('goog.events.EventType'); goog.require('goog.functions'); goog.require('goog.object'); goog.require('goog.vec.Mat4'); @@ -47,11 +49,17 @@ ol.source.Raster = function(options) { var operations = goog.isDef(options.operations) ? options.operations : [ol.raster.IdentityOp]; + /** + * @private + * @type {number} + */ + this.threads_ = goog.isDef(options.threads) ? options.threads : 1; + /** * @private * @type {*} */ - this.worker_ = this.createWorker_(operations); + this.worker_ = this.createWorker_(operations, options.lib, this.threads_); /** * @private @@ -59,6 +67,11 @@ ol.source.Raster = function(options) { */ this.renderers_ = ol.source.Raster.createRenderers_(options.sources); + for (var r = 0, rr = this.renderers_.length; r < rr; ++r) { + goog.events.listen(this.renderers_[r], goog.events.EventType.CHANGE, + this.changed, false, this); + } + /** * @private * @type {CanvasRenderingContext2D} @@ -134,14 +147,19 @@ goog.inherits(ol.source.Raster, ol.source.Image); /** * Create a worker. * @param {Array.} operations The operations. + * @param {Object=} opt_lib Optional lib functions. + * @param {number=} opt_threads Number of threads. * @return {*} The worker. * @private */ -ol.source.Raster.prototype.createWorker_ = function(operations) { +ol.source.Raster.prototype.createWorker_ = + function(operations, opt_lib, opt_threads) { return new ol.ext.pixelworks.Processor({ operations: operations, imageOps: this.operationType_ === ol.raster.OperationType.IMAGE, - queue: 1 + queue: 1, + lib: opt_lib, + threads: opt_threads }); }; @@ -149,10 +167,12 @@ ol.source.Raster.prototype.createWorker_ = function(operations) { /** * Reset the operations. * @param {Array.} operations New operations. + * @param {Object=} opt_lib Functions that will be available to operations run + * in a worker. * @api */ -ol.source.Raster.prototype.setOperations = function(operations) { - this.worker_ = this.createWorker_(operations); +ol.source.Raster.prototype.setOperations = function(operations, opt_lib) { + this.worker_ = this.createWorker_(operations, opt_lib, this.threads_); this.changed(); }; @@ -281,10 +301,15 @@ ol.source.Raster.prototype.composeFrame_ = function(frameState, callback) { var len = this.renderers_.length; var imageDatas = new Array(len); for (var i = 0; i < len; ++i) { - imageDatas[i] = ol.source.Raster.getImageData_( + var imageData = ol.source.Raster.getImageData_( this.renderers_[i], frameState, frameState.layerStatesArray[i]); + if (imageData) { + imageDatas[i] = imageData; + } else { + // image not yet ready + return; + } } - frameState.tileQueue.loadMoreTiles(16, 16); var data = {}; this.dispatchEvent(new ol.source.RasterEvent( @@ -292,6 +317,8 @@ ol.source.Raster.prototype.composeFrame_ = function(frameState, callback) { this.worker_.process(imageDatas, data, this.onWorkerComplete_.bind(this, frameState, callback)); + + frameState.tileQueue.loadMoreTiles(16, 16); }; @@ -337,16 +364,50 @@ ol.source.Raster.prototype.onWorkerComplete_ = */ ol.source.Raster.getImageData_ = function(renderer, frameState, layerState) { renderer.prepareFrame(frameState, layerState); - var canvas = renderer.getImage(); + // We should be able to call renderer.composeFrame(), but this is inefficient + // for tiled sources (we've already rendered to an intermediate canvas in the + // prepareFrame call and we don't need to render again to the output canvas). + // TODO: make all canvas renderers render to a single canvas + var image = renderer.getImage(); + if (!image) { + return null; + } var imageTransform = renderer.getImageTransform(); - var dx = goog.vec.Mat4.getElement(imageTransform, 0, 3); - var dy = goog.vec.Mat4.getElement(imageTransform, 1, 3); - return canvas.getContext('2d').getImageData( - Math.round(-dx), Math.round(-dy), - frameState.size[0], frameState.size[1]); + var dx = Math.round(goog.vec.Mat4.getElement(imageTransform, 0, 3)); + var dy = Math.round(goog.vec.Mat4.getElement(imageTransform, 1, 3)); + var width = frameState.size[0]; + var height = frameState.size[1]; + if (image instanceof Image) { + if (!ol.source.Raster.context_) { + ol.source.Raster.context_ = ol.dom.createCanvasContext2D(width, height); + } else { + var canvas = ol.source.Raster.context_.canvas; + if (canvas.width !== width || canvas.height !== height) { + ol.source.Raster.context_ = ol.dom.createCanvasContext2D(width, height); + } else { + ol.source.Raster.context_.clearRect(0, 0, width, height); + } + } + var dw = Math.round( + image.width * goog.vec.Mat4.getElement(imageTransform, 0, 0)); + var dh = Math.round( + image.height * goog.vec.Mat4.getElement(imageTransform, 1, 1)); + ol.source.Raster.context_.drawImage(image, dx, dy, dw, dh); + return ol.source.Raster.context_.getImageData(0, 0, width, height); + } else { + return image.getContext('2d').getImageData(-dx, -dy, width, height); + } }; +/** + * A reusable canvas context. + * @type {CanvasRenderingContext2D} + * @private + */ +ol.source.Raster.context_ = null; + + /** * Get a list of layer states from a list of renderers. * @param {Array.} renderers Layer renderers. diff --git a/test/spec/ol/source/rastersource.test.js b/test/spec/ol/source/rastersource.test.js new file mode 100644 index 0000000000..69e8f71914 --- /dev/null +++ b/test/spec/ol/source/rastersource.test.js @@ -0,0 +1,307 @@ +goog.provide('ol.test.source.RasterSource'); + +var red = 'data:image/gif;base64,R0lGODlhAQABAPAAAP8AAP///yH5BAAAAAAALAAAAAA' + + 'BAAEAAAICRAEAOw=='; + +var green = 'data:image/gif;base64,R0lGODlhAQABAPAAAAD/AP///yH5BAAAAAAALAAAA' + + 'AABAAEAAAICRAEAOw=='; + +var blue = 'data:image/gif;base64,R0lGODlhAQABAPAAAAAA/////yH5BAAAAAAALAAAAA' + + 'ABAAEAAAICRAEAOw=='; + +function itNoPhantom() { + if (window.mochaPhantomJS) { + return xit.apply(this, arguments); + } else { + return it.apply(this, arguments); + } +} + +describe('ol.source.Raster', function() { + + var target, map, redSource, greenSource, blueSource; + + beforeEach(function() { + target = document.createElement('div'); + + var style = target.style; + style.position = 'absolute'; + style.left = '-1000px'; + style.top = '-1000px'; + style.width = '2px'; + style.height = '2px'; + document.body.appendChild(target); + + var extent = [-1, -1, 1, 1]; + + redSource = new ol.source.ImageStatic({ + url: red, + imageExtent: extent + }); + + greenSource = new ol.source.ImageStatic({ + url: green, + imageExtent: extent + }); + + blueSource = new ol.source.ImageStatic({ + url: blue, + imageExtent: extent + }); + + raster = new ol.source.Raster({ + threads: 0, + sources: [redSource, greenSource, blueSource], + operations: [function(inputs) { + return inputs; + }] + }); + + map = new ol.Map({ + target: target, + view: new ol.View({ + resolutions: [1], + projection: new ol.proj.Projection({ + code: 'image', + units: 'pixels', + extent: extent + }) + }), + layers: [ + new ol.layer.Image({ + source: raster + }) + ] + }); + }); + + afterEach(function() { + goog.dispose(map); + document.body.removeChild(target); + }); + + describe('constructor', function() { + + it('returns a tile source', function() { + var source = new ol.source.Raster({ + threads: 0, + sources: [new ol.source.Tile({})] + }); + expect(source).to.be.a(ol.source.Source); + expect(source).to.be.a(ol.source.Raster); + }); + + itNoPhantom('defaults to "pixel" operations', function(done) { + + var log = []; + + raster = new ol.source.Raster({ + threads: 0, + sources: [redSource, greenSource, blueSource], + operations: [function(inputs) { + log.push(inputs); + return inputs; + }] + }); + + raster.on('afteroperations', function() { + expect(log.length).to.equal(4); + var inputs = log[0]; + var pixel = inputs[0]; + expect(pixel).to.be.an('array'); + done(); + }); + + map.getLayers().item(0).setSource(raster); + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + }); + + itNoPhantom('allows operation type to be set to "image"', function(done) { + + var log = []; + + raster = new ol.source.Raster({ + operationType: ol.raster.OperationType.IMAGE, + threads: 0, + sources: [redSource, greenSource, blueSource], + operations: [function(inputs) { + log.push(inputs); + return inputs; + }] + }); + + raster.on('afteroperations', function() { + expect(log.length).to.equal(1); + var inputs = log[0]; + expect(inputs[0]).to.be.an(ImageData); + done(); + }); + + map.getLayers().item(0).setSource(raster); + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + }); + + }); + + describe('#setOperations()', function() { + + itNoPhantom('allows operations to be set', function(done) { + + var count = 0; + raster.setOperations([function(pixels) { + ++count; + var redPixel = pixels[0]; + var greenPixel = pixels[1]; + var bluePixel = pixels[2]; + expect(redPixel).to.eql([255, 0, 0, 255]); + expect(greenPixel).to.eql([0, 255, 0, 255]); + expect(bluePixel).to.eql([0, 0, 255, 255]); + return pixels; + }]); + + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + raster.on('afteroperations', function(event) { + expect(count).to.equal(4); + done(); + }); + + }); + + itNoPhantom('updates and re-runs the operations', function(done) { + + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + var count = 0; + raster.on('afteroperations', function(event) { + ++count; + if (count === 1) { + raster.setOperations([function(inputs) { + return inputs; + }]); + } else { + done(); + } + }); + + }); + + }); + + describe('beforeoperations', function() { + + itNoPhantom('gets called before operations are run', function(done) { + + var count = 0; + raster.setOperations([function(inputs) { + ++count; + return inputs; + }]); + + raster.on('beforeoperations', function(event) { + expect(count).to.equal(0); + expect(!!event).to.be(true); + expect(event.extent).to.be.an('array'); + expect(event.resolution).to.be.a('number'); + expect(event.data).to.be.an('object'); + done(); + }); + + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + }); + + + itNoPhantom('allows data to be set for the operations', function(done) { + + raster.setOperations([function(inputs, data) { + ++data.count; + return inputs; + }]); + + raster.on('beforeoperations', function(event) { + event.data.count = 0; + }); + + raster.on('afteroperations', function(event) { + expect(event.data.count).to.equal(4); + done(); + }); + + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + }); + + }); + + describe('afteroperations', function() { + + itNoPhantom('gets called after operations are run', function(done) { + + var count = 0; + raster.setOperations([function(inputs) { + ++count; + return inputs; + }]); + + raster.on('afteroperations', function(event) { + expect(count).to.equal(4); + expect(!!event).to.be(true); + expect(event.extent).to.be.an('array'); + expect(event.resolution).to.be.a('number'); + expect(event.data).to.be.an('object'); + done(); + }); + + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + }); + + itNoPhantom('receives data set by the operations', function(done) { + + raster.setOperations([function(inputs, data) { + data.message = 'hello world'; + return inputs; + }]); + + raster.on('afteroperations', function(event) { + expect(event.data.message).to.equal('hello world'); + done(); + }); + + var view = map.getView(); + view.setCenter([0, 0]); + view.setZoom(0); + + }); + + }); + +}); + +goog.require('ol.Map'); +goog.require('ol.View'); +goog.require('ol.layer.Image'); +goog.require('ol.proj.Projection'); +goog.require('ol.raster.OperationType'); +goog.require('ol.source.Image'); +goog.require('ol.source.ImageStatic'); +goog.require('ol.source.Raster'); +goog.require('ol.source.Source'); +goog.require('ol.source.Tile');