diff --git a/Gruntfile.js b/Gruntfile.js index 979a602a..b0f452ce 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -67,13 +67,13 @@ module.exports = function(grunt) { "src/tilecache.js", "src/world.js", - // Aiosa's webgl drawer - needs optimization, polishing, trimming - // "src/webgl/webGLWrapper.js", - // "src/webgl/visualisationLayer.js", - // "src/webgl/dataLoader.js", - // "src/webgl/webGLContext.js", - // "src/webgl/drawer.js", - // "src/webgl/plainShader.js", + //Aiosa's webgl drawer - needs optimization, polishing, trimming + "src/webgl/renderer.js", + "src/webgl/shaderLayer.js", + "src/webgl/dataLoader.js", + "src/webgl/webGLContext.js", + "src/webgl/drawer.js", + "src/webgl/plainShader.js", ]; var banner = "//! <%= pkg.name %> <%= pkg.version %>\n" + @@ -204,7 +204,7 @@ module.exports = function(grunt) { } }, watch: { - files: [ "Gruntfile.js", "src/*.js", "images/*" /*, "src/webgl/*.js" */ ], + files: [ "Gruntfile.js", "src/*.js", "images/*", "src/webgl/*.js" ], tasks: "watchTask" }, eslint: { diff --git a/src/viewer.js b/src/viewer.js index 4e823743..b11233a6 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -470,7 +470,7 @@ $.Viewer = function( options ) { } // if the drawer is supported, create it and break the loop - if (Drawer.isSupported()) { + if (Drawer && Drawer.isSupported()) { this.drawer = new Drawer({ viewer: this, viewport: this.viewport, diff --git a/src/webgl/dataLoader.js b/src/webgl/dataLoader.js new file mode 100644 index 00000000..6e800aba --- /dev/null +++ b/src/webgl/dataLoader.js @@ -0,0 +1,542 @@ + +(function($) { + +/** + * IDataLoader conforms to a specific texture type and WebGL version. + * It provides API for uniform handling of textures: + * - texture loading + * - GLSL texture handling + */ +$.WebGLModule.IDataLoader = class { + /** + * Creation + * @param {WebGLRenderingContextBase} gl + * @param {string} webglVersion + * @param {object} options + * @param {GLuint} options.wrap texture wrap parameteri + * @param {GLuint} options.magFilter texture filter parameteri + * @param {GLuint} options.minFilter texture filter parameteri + * */ + constructor(gl, webglVersion, options) { + //texture cache to keep track of loaded GPU data + this.__cache = new Map(); + this.wrap = options.wrap; + this.minFilter = options.minFilter; + this.magFilter = options.magFilter; + this.maxTextures = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS); + + /** + * Loader strategy based on toString result, extend with your type if necessary. + * If your type cannot use the given version strategy (TEXTURE_2D_ARRAY UNIT), you have + * to re-define the whole API. + * + * When a data is sent to the shader for processing, `toString` method is called to + * get the data identifier. A typeLoaders key must be present to handle loading + * of that texture(s) data. + * @member typeLoaders + * @memberOf OpenSeadragon.WebGLModule.IDataLoader + * + * @return {object} whatever you need to stare in the cache to later free the object + */ + this.typeLoaders = {}; + } + + /** + * @param {string} version + * @return {boolean} true if given webgl version is supported by the loader + */ + supportsWebglVersion(version) { + throw("::supportsWebglVersion must be implemented!"); + } + + /** + * Get stored options under ID + * @param id + * @return {unknown} options stored by setLoaded + */ + getLoaded(id) { + return this.__cache.get(id); + } + + /** + * Store options object + * @param id + * @param options + */ + setLoaded(id, options) { + this.__cache.set(id, options); + } + + /** + * Unload stored options + * @param id + */ + setUnloaded(id) { + this.__cache.delete(id); + } + + /** + * Set texture sampling parameters + * @param {string} name one of 'minFilter', 'magFilter', 'wrap' + * @param {GLuint} value + */ + setTextureParam(name, value) { + if (!['minFilter', 'magFilter', 'wrap'].includes(name)) { + return; + } + this[name] = value; + } + + /** + * + * @param renderer + * @param id + * @param options + */ + unloadTexture(renderer, id, options) { + throw("::unloadTexture must be implemented!"); + } + + /** + * @param {OpenSeadragon.WebGLModule} renderer renderer renderer reference + * @param id + * @param data + * @param width + * @param height + * @param {[number]} shaderDataIndexToGlobalDataIndex mapping of array indices to data indices, e.g. texture 0 for + * this shader corresponds to index shaderDataIndexToGlobalDataIndex[0] in the data array, + * -1 value used for textures not loaded + */ + load(renderer, id, data, width, height, shaderDataIndexToGlobalDataIndex) { + if (!data) { + $.console.warn("Attempt to draw nullable data!"); + return; + } + const textureLoader = this.typeLoaders[toString.apply(data)]; + if (!textureLoader) { + throw "WebGL Renderer cannot load data as texture: " + toString.apply(data); + } + this.setLoaded(id, textureLoader(this, renderer, data, width, height, shaderDataIndexToGlobalDataIndex)); + } + + /** + * + * @param renderer + * @param id + */ + free(renderer, id) { + this.unloadTexture(renderer, id, this.getLoaded(id)); + this.setUnloaded(id); + } + + /** + * Called when the program is being loaded (set as active) + * @param {OpenSeadragon.WebGLModule} renderer + * @param {WebGLRenderingContextBase} gl WebGL context + * @param {WebGLProgram} program + * @param {object} specification reference to the specification object used + */ + programLoaded(renderer, gl, program, specification) { + //not needed + } + + /** + * Called when tile is processed + * @param {OpenSeadragon.WebGLModule} renderer renderer renderer reference + * @param {object} specification reference to the current active specification object + * @param {*} id data object present in the texture cache + * @param {WebGLProgram} program current WebGLProgram + * @param {WebGL2RenderingContext} gl + */ + programUsed(renderer, specification, id, program, gl) { + + } + + /** + * Sample texture + * @param {number|string} index texture index, must respect index re-mapping (see declare()) + * @param {string} vec2coords GLSL expression that evaluates to vec2 + * @return {string} GLSL expression (unterminated) that evaluates to vec4 + */ + sample(index, vec2coords) { + return `texture(_vis_data_sampler_array, vec3(${vec2coords}, _vis_data_sampler_array_indices[${index}]))`; + } + + /** + * Declare GLSL texture logic (global scope) in the GLSL shader + * @param {[number]} shaderDataIndexToGlobalDataIndex mapping of array indices to data indices, e.g. texture 0 for + * this shader corresponds to index shaderDataIndexToGlobalDataIndex[0] in the data array, + * -1 value used for textures not loaded + * @return {string} GLSL declaration (terminated with semicolon) of necessary elements for textures + */ + declare(shaderDataIndexToGlobalDataIndex) { + return ` +vec4 osd_texture(float index, vec2 coords) { + //This method must be implemented! +} + +//TODO: is this relevant? +// vec2 osd_texture_size() { +// //This method must be implemented! +// } +`; + } +}; + +/** + * Data loading strategies for different WebGL versions. + * Should you have your own data format, change/re-define these + * to correctly load the textures to GPU, based on the WebGL version used. + * + * The processing accepts arrays of images to feed to the shader built from configuration. + * This implementation supports data as Image or Canvas objects. We will refer to them as + * + * Implemented texture loaders support + * - working with object - image data chunks are vertically concatenated + * - working with [] object - images are in array + * + * @namespace OpenSeadragon.WebGLModule.Loaders + */ +$.WebGLModule.Loaders = { + + /** + * //TODO: ugly + * In case the system is fed by anything but 'Image' (or the like) data object, + * implement here conversion so that debug mode can draw it. + * @param {*} data + * @return {HTMLElement} Dom Element + */ + dataAsHtmlElement: function(data) { + return { + "[object HTMLImageElement]": () => data, + "[object HTMLCanvasElement]": () => data, + //Image objects in Array, we assume image objects only + "[object Array]": function() { + const node = document.createElement("div"); + for (let image of data) { + node.append(image); + } + return node; + } + }[toString.apply(data)](); + }, + + /** + * Data loader for WebGL 2.0. Must load the data to a Texture2DArray. + * The name of the texture is a constant. The order od the textures in + * the z-stacking is defined in shaderDataIndexToGlobalDataIndex. + * + * For details, please, see the implementation. + * @class OpenSeadragon.WebGLModule.Loaders.TEXTURE_2D_ARRAY + */ + TEXTURE_2D_ARRAY: class /**@lends $.WebGLModule.Loaders.TEXTURE_2D_ARRAY */ extends OpenSeadragon.WebGLModule.IDataLoader { + unloadTexture(renderer, id, options) { + renderer.gl.deleteTexture(options); + } + + /** + * Creation + * @param {WebGL2RenderingContext} gl + * @param {string} webglVersion + * @param {object} options + * @param {GLuint} options.wrap texture wrap parameteri + * @param {GLuint} options.magFilter texture filter parameteri + * @param {GLuint} options.minFilter texture filter parameteri + * @memberOf OpenSeadragon.WebGLModule.Loaders.TEXTURE_2D_ARRAY + * */ + constructor(gl, webglVersion, options) { + super(gl, webglVersion, options); + + if (webglVersion !== "2.0") { + throw "Incompatible WebGL version for TEXTURE_2D_ARRAY data loader!"; + } + + // this.batchSize = 5; + // let lastBatch = null; + + this.typeLoaders["[object HTMLImageElement]"] = + this.typeLoaders["[object HTMLCanvasElement]"] = function (self, webglModule, data, width, height, + shaderDataIndexToGlobalDataIndex) { + + const NUM_IMAGES = Math.round(data.height / height); + const gl = webglModule.gl; + + // //todo different tile sizes are problems + // if (!lastBatch || lastBatch.length < NUM_IMAGES) { + // lastBatch = { + // texId: gl.createTexture(), + // length: this.batchSize, + // texCount: 0, + // }; + // gl.bindTexture(gl.TEXTURE_2D_ARRAY, options.texId); + // gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, data[0].width, data[0].height, data.length + 1); + // } else { + // gl.bindTexture(gl.TEXTURE_2D_ARRAY, options.texId); + // } + + const textureId = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, textureId); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, self.magFilter); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, self.minFilter); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, self.wrap); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, self.wrap); + + gl.texImage3D( + gl.TEXTURE_2D_ARRAY, + 0, + gl.RGBA, + width, + height, + NUM_IMAGES, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + data + ); + + return textureId; + }; + + //Image objects in Array, we assume image objects only todo ugly, can be array of anything + this.typeLoaders["[object Array]"] = function (self, webglModule, data, options, width, height, shaderDataIndexToGlobalDataIndex) { + const gl = webglModule.gl; + const textureId = gl.createTexture(); + + //gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, textureId); + gl.texStorage3D(gl.TEXTURE_2D_ARRAY, 1, gl.RGBA8, data[0].width, data[0].height, data.length + 1); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAX_LEVEL, 0); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, self.minFilter); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, self.magFilter); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, self.wrap); + gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, self.wrap); + + let index = 0; + for (let image of data) { + gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, index++, image.width, image.height, + 1, gl.RGBA, gl.UNSIGNED_BYTE, image); + } + return textureId; + }; + } + + supportsWebglVersion(version) { + return version === "2.0"; + } + + programUsed(renderer, specification, id, program, gl) { + gl.activeTexture(gl.TEXTURE0); + const tid = this.getLoaded(id); + gl.bindTexture(gl.TEXTURE_2D_ARRAY, tid); + } + + sample(index, vec2coords) { + return `texture(_vis_data_sampler_array, vec3(${vec2coords}, _vis_data_sampler_array_indices[${index}]))`; + } + + declare(shaderDataIndexToGlobalDataIndex) { + return `uniform sampler2DArray _vis_data_sampler_array; +int _vis_data_sampler_array_indices[${shaderDataIndexToGlobalDataIndex.length}] = int[${shaderDataIndexToGlobalDataIndex.length}]( + ${shaderDataIndexToGlobalDataIndex.join(",")} +); + +vec4 osd_texture(int index, vec2 coords) { + return ${this.sample("index", "coords")}; +} +`; + } + }, + + + /** + * Data loader for WebGL 2.0. Must load the data to a Texture2DArray. + * The name of the texture is a constant. The order od the textures in + * the z-stacking is defined in shaderDataIndexToGlobalDataIndex. + * + * For details, please, see the implementation. + * @class OpenSeadragon.WebGLModule.Loaders.TEXTURE_2D + */ + TEXTURE_2D: class /**@lends $.WebGLModule.Loaders.TEXTURE_2D */ extends OpenSeadragon.WebGLModule.IDataLoader { + + /** + * Creation + * @param {WebGL2RenderingContext} gl + * @param {string} webglVersion + * @param {object} options + * @param {GLuint} options.wrap texture wrap parameteri + * @param {GLuint} options.magFilter texture filter parameteri + * @param {GLuint} options.minFilter texture filter parameteri + * @memberOf OpenSeadragon.WebGLModule.Loaders.TEXTURE_2D + * */ + constructor(gl, webglVersion, options) { + super(gl, webglVersion, options); + + this._samples = webglVersion === "1.0" ? "texture2D" : "texture"; + + this.typeLoaders["[object HTMLImageElement]"] = + this.typeLoaders["[object HTMLCanvasElement]"] = function (self, webglModule, data, width, height, + shaderDataIndexToGlobalDataIndex) { + + //Avoid canvas slicing if possible + const NUM_IMAGES = Math.round(data.height / height); + if (NUM_IMAGES === 1) { + const texture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, self.wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, self.wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, self.minFilter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, self.magFilter); + gl.texImage2D(gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + data); + return [texture]; + } + + if (!self._canvas) { + self._canvas = document.createElement('canvas'); + self._canvasReader = self._canvas.getContext('2d', {willReadFrequently: true}); + self._canvasConverter = document.createElement('canvas'); + self._canvasConverterReader = self._canvasConverter.getContext('2d', + {willReadFrequently: true}); + } + + let index = 0; + width = Math.round(width); + height = Math.round(height); + + const units = []; + + //we read from here + self._canvas.width = data.width; + self._canvas.height = data.height; + self._canvasReader.drawImage(data, 0, 0); + + //Allowed texture size dimension only 256+ and power of two... + + //it worked for arbitrary size until we begun with image arrays... is it necessary? + const IMAGE_SIZE = data.width < 256 ? 256 : Math.pow(2, Math.ceil(Math.log2(data.width))); + self._canvasConverter.width = IMAGE_SIZE; + self._canvasConverter.height = IMAGE_SIZE; + + //just load all images and let shaders reference them... + for (let i = 0; i < shaderDataIndexToGlobalDataIndex.length; i++) { + if (shaderDataIndexToGlobalDataIndex[i] < 0) { + continue; + } + if (index >= NUM_IMAGES) { + console.warn("The visualisation contains less data than layers. Skipping layers ..."); + return units; + } + + units.push(gl.createTexture()); + gl.bindTexture(gl.TEXTURE_2D, units[index]); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, self.wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, self.wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, self.minFilter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, self.magFilter); + + let pixels; + if (width !== IMAGE_SIZE || height !== IMAGE_SIZE) { + self._canvasConverterReader.drawImage(self._canvas, 0, + shaderDataIndexToGlobalDataIndex[i] * height, + width, height, 0, 0, IMAGE_SIZE, IMAGE_SIZE); + + pixels = self._canvasConverterReader.getImageData(0, 0, IMAGE_SIZE, IMAGE_SIZE); + } else { + //load data + pixels = self._canvasReader.getImageData(0, + shaderDataIndexToGlobalDataIndex[i] * height, width, height); + } + + gl.texImage2D(gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + pixels); + index++; + } + return units; + }; + + //Image objects in Array, we assume image objects only todo ugly, can be array of anything + this.typeLoaders["[object Array]"] = function (self, webglModule, data, options, width, height, shaderDataIndexToGlobalDataIndex) { + const gl = webglModule.gl; + + let index = 0; + const NUM_IMAGES = data.length; + const units = []; + + //just load all images and let shaders reference them... + for (let i = 0; i < shaderDataIndexToGlobalDataIndex.length; i++) { + if (shaderDataIndexToGlobalDataIndex[i] < 0) { + continue; + } + if (index >= NUM_IMAGES) { + console.warn("The visualisation contains less data than layers. Skipping layers ..."); + return units; + } + + //create textures + units.push(gl.createTexture()); + gl.bindTexture(gl.TEXTURE_2D, units[index]); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, self.wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, self.wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, self.minFilter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, self.magFilter); + //do not check the image size, we render what wwe + gl.texImage2D(gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + data[index++] + ); + } + return units; + }; + } + + unloadTexture(renderer, id, options) { + for (let textureUnit of options) { + renderer.gl.deleteTexture(textureUnit); + } + } + + supportsWebglVersion(version) { + return true; + } + + programUsed(renderer, specification, id, program, gl) { + const units = this.getLoaded(id); + for (let i = 0; i < units.length; i++) { + let textureUnit = units[i]; + let bindConst = `TEXTURE${i}`; + gl.activeTexture(gl[bindConst]); + gl.bindTexture(gl.TEXTURE_2D, textureUnit); + let location = gl.getUniformLocation(program, `vis_data_sampler_${i}`); + gl.uniform1i(location, i); + } + } + + sample(index, vec2coords) { + return `${this._samples}(vis_data_sampler_${index}, ${vec2coords})`; + } + + declare(shaderDataIndexToGlobalDataIndex) { + let samplers = 'uniform vec2 sampler_size;'; + for (let i = 0; i < shaderDataIndexToGlobalDataIndex.length; i++) { + if (shaderDataIndexToGlobalDataIndex[i] === -1) { + continue; + } + samplers += `uniform sampler2D vis_data_sampler_${i};`; + } + return samplers; + } + }, +}; + +})(OpenSeadragon); diff --git a/src/webgl/drawer.js b/src/webgl/drawer.js new file mode 100644 index 00000000..3b20f8ea --- /dev/null +++ b/src/webgl/drawer.js @@ -0,0 +1,289 @@ + +/* + * OpenSeadragon - WebGLDrawer + * + * Copyright (C) 2010-2023 OpenSeadragon contributors + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * - Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * - Neither the name of CodePlex Foundation nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +(function( $ ){ + +/** + * @class WebGLDrawer + * @memberof OpenSeadragon + * @classdesc Default implementation of WebGLDrawer for an {@link OpenSeadragon.Viewer}. + * @param {Object} options - Options for this Drawer. + * @param {OpenSeadragon.Viewer} options.viewer - The Viewer that owns this Drawer. + * @param {OpenSeadragon.Viewport} options.viewport - Reference to Viewer viewport. + * @param {Element} options.element - Parent element. + * @param {Number} [options.debugGridColor] - See debugGridColor in {@link OpenSeadragon.Options} for details. + */ + +$.WebGL = class WebGL extends OpenSeadragon.DrawerBase { + constructor(options){ + super(options); + + this.destroyed = false; + // Add listeners for events that require modifying the scene or camera + this.viewer.addHandler("tile-ready", this._tileReadyHandler.bind(this)); + this.viewer.addHandler("image-unloaded", this.renderer.freeData.bind(this.renderer)); + } + + // Public API required by all Drawer implementations + /** + * Clean up the renderer, removing all resources + */ + destroy(){ + if(this.destroyed){ + return; + } + //todo + this.destroyed = true; + } + + // Public API required by all Drawer implementations + /** + * + * @returns true if the drawer supports rotation + */ + canRotate(){ + return true; + } + + // Public API required by all Drawer implementations + + /** + * @returns {Boolean} returns true if canvas and webgl are supported + */ + static isSupported(){ + return true; //todo + } + + getType() { + return 'universal_webgl'; + } + + /** + * create the HTML element (canvas in this case) that the image will be drawn into + * @returns {Element} the canvas to draw into + */ + createDrawingElement(){ + + const engine = new $.WebGLModule($.extend(this.options, { + uniqueId: "openseadragon", + })); + + engine.addRenderingSpecifications({ + shaders: { + renderShader: { + type: "identity", + dataReferences: [0], + } + } + }); + + engine.prepare(); + + const size = this._calculateCanvasSize(); + engine.init(size.x, size.y); + this.viewer.addHandler("resize", this._resizeRenderer.bind(this)); + this.renderer = engine; + return engine.canvas; + } + + /** + * + * @param {Array} tiledImages Array of TiledImage objects to draw + */ + draw(tiledImages){ + let viewport = { + bounds: this.viewport.getBoundsNoRotate(true), + center: this.viewport.getCenter(true), + rotation: this.viewport.getRotation(true) * Math.PI / 180, + zoom: this.viewport.getZoom(true) + }; + + let flipMultiplier = this.viewport.flipped ? -1 : 1; + // calculate view matrix for viewer + let posMatrix = $.Mat3.makeTranslation(-viewport.center.x, -viewport.center.y); + let scaleMatrix = $.Mat3.makeScaling(2 / viewport.bounds.width * flipMultiplier, -2 / viewport.bounds.height); + let rotMatrix = $.Mat3.makeRotation(-viewport.rotation); + let viewMatrix = scaleMatrix.multiply(rotMatrix).multiply(posMatrix); + + this.renderer.clear(); + this.renderer.setDataBlendingEnabled(false); + + //iterate over tiled images and draw each one using a two-pass rendering pipeline if needed + + let drawn = 0; + + for (const tiledImage of tiledImages) { + let tilesToDraw = tiledImage.getTilesToDraw(); + + if (tilesToDraw.length === 0) { + break; + } + + if (drawn === 1) { + this.renderer.setDataBlendingEnabled(true); + } + + let overallMatrix = viewMatrix; + let imageRotation = tiledImage.getRotation(true); + // if needed, handle the tiledImage being rotated + if( imageRotation % 360 !== 0){ + let imageRotationMatrix = $.Mat3.makeRotation(-imageRotation * Math.PI / 180); + let imageCenter = tiledImage.getBoundsNoRotate(true).getCenter(); + let t1 = $.Mat3.makeTranslation(imageCenter.x, imageCenter.y); + let t2 = $.Mat3.makeTranslation(-imageCenter.x, -imageCenter.y); + + // update the view matrix to account for this image's rotation + let localMatrix = t1.multiply(imageRotationMatrix).multiply(t2); + overallMatrix = viewMatrix.multiply(localMatrix); + } + + //todo better access to the rendering context + const shader = this.renderer.specification(0).shaders.renderShader._renderContext; + + // iterate over tiles and add data for each one to the buffers + for (let tileIndex = 0; tileIndex < tilesToDraw.length; tileIndex++){ + const tile = tilesToDraw[tileIndex].tile; + const matrix = this._getTileMatrix(tile, tiledImage, overallMatrix); + shader.opacity.set(tile.opacity * tiledImage.opacity); + + //todo pixelSize value (not yet memoized) + this.renderer.processData(tile.cacheKey, { + transform: matrix, zoom: + viewport.zoom, + pixelSize: 0 + }); + } + + // Fire tiled-image-drawn event. + // TODO: the image data may not be on the output canvas yet!! + if( this.viewer ){ + /** + * Raised when a tiled image is drawn to the canvas. Only valid + * for webgl drawer. + * + * @event tiled-image-drawn + * @memberof OpenSeadragon.Viewer + * @type {object} + * @property {OpenSeadragon.Viewer} eventSource - A reference to the Viewer which raised the event. + * @property {OpenSeadragon.TiledImage} tiledImage - Which TiledImage is being drawn. + * @property {Array} tiles - An array of Tile objects that were drawn. + * @property {?Object} userData - Arbitrary subscriber-defined object. + */ + this.viewer.raiseEvent( 'tiled-image-drawn', { + tiledImage: tiledImage, + tiles: tilesToDraw.map(info => info.tile), + }); + } + + drawn++; + } + } + + /** + * Set the context2d imageSmoothingEnabled parameter + * @param {Boolean} enabled + */ + setImageSmoothingEnabled(enabled){ + //todo + // this._clippingContext.imageSmoothingEnabled = enabled; + // this._outputContext.imageSmoothingEnabled = enabled; + } + + // private + _getTileMatrix(tile, tiledImage, viewMatrix){ + // compute offsets that account for tile overlap; needed for calculating the transform matrix appropriately + let overlapFraction = this._calculateOverlapFraction(tile, tiledImage); + let xOffset = tile.positionedBounds.width * overlapFraction.x; + let yOffset = tile.positionedBounds.height * overlapFraction.y; + + // x, y, w, h in viewport coords + let x = tile.positionedBounds.x + (tile.x === 0 ? 0 : xOffset); + let y = tile.positionedBounds.y + (tile.y === 0 ? 0 : yOffset); + let right = tile.positionedBounds.x + tile.positionedBounds.width - (tile.isRightMost ? 0 : xOffset); + let bottom = tile.positionedBounds.y + tile.positionedBounds.height - (tile.isBottomMost ? 0 : yOffset); + let w = right - x; + let h = bottom - y; + + let matrix = new $.Mat3([ + w, 0, 0, + 0, h, 0, + x, y, 1, + ]); + + if(tile.flipped){ + // flip the tile around the center of the unit quad + let t1 = $.Mat3.makeTranslation(0.5, 0); + let t2 = $.Mat3.makeTranslation(-0.5, 0); + + // update the view matrix to account for this image's rotation + let localMatrix = t1.multiply($.Mat3.makeScaling(-1, 1)).multiply(t2); + matrix = matrix.multiply(localMatrix); + } + let overallMatrix = viewMatrix.multiply(matrix); + return overallMatrix.values; + } + + _resizeRenderer(){ + const size = this._calculateCanvasSize(); + this.renderer.setDimensions(0, 0, size.x, size.y); + } + + _imageUnloadedHandler(event){ + this.renderer.freeData(event.tile.cacheKey); + } + + _tileReadyHandler(event){ + //todo tile overlap + let tile = event.tile; + //todo fix cache system and then this line + //access by default raw tile data, and only access canvas if not cache set + let data = tile.cacheImageRecord ? tile.cacheImageRecord.getData() : tile.getCanvasContext().canvas; + this.renderer.loadData(tile.cacheKey, data, tile.sourceBounds.width, tile.sourceBounds.height); + } + + _calculateOverlapFraction(tile, tiledImage){ + let overlap = tiledImage.source.tileOverlap; + let nativeWidth = tile.sourceBounds.width; // in pixels + let nativeHeight = tile.sourceBounds.height; // in pixels + let overlapWidth = (tile.x === 0 ? 0 : overlap) + (tile.isRightMost ? 0 : overlap); // in pixels + let overlapHeight = (tile.y === 0 ? 0 : overlap) + (tile.isBottomMost ? 0 : overlap); // in pixels + let widthOverlapFraction = overlap / (nativeWidth + overlapWidth); // as a fraction of image including overlap + let heightOverlapFraction = overlap / (nativeHeight + overlapHeight); // as a fraction of image including overlap + return { + x: widthOverlapFraction, + y: heightOverlapFraction + }; + } +}; +}( OpenSeadragon )); diff --git a/src/webgl/plainShader.js b/src/webgl/plainShader.js new file mode 100644 index 00000000..ed7e7f05 --- /dev/null +++ b/src/webgl/plainShader.js @@ -0,0 +1,40 @@ +(function($) { + /** + * Identity shader + * + * data reference must contain one index to the data to render using identity + */ + $.WebGLModule.IdentityLayer = class extends $.WebGLModule.ShaderLayer { + + static type() { + return "identity"; + } + + static name() { + return "Identity"; + } + + static description() { + return "shows the data AS-IS"; + } + + static sources() { + return [{ + acceptsChannelCount: (x) => x === 4, + description: "4d texture to render AS-IS" + }]; + } + + getFragmentShaderExecution() { + return `return ${this.sampleChannel("tile_texture_coords")};`; + } + }; + +//todo why cannot be inside object :/ +$.WebGLModule.IdentityLayer.defaultControls["use_channel0"] = { + required: "rgba" +}; + +$.WebGLModule.ShaderMediator.registerLayer($.WebGLModule.IdentityLayer); + +})(OpenSeadragon); diff --git a/src/webgl/renderer.js b/src/webgl/renderer.js new file mode 100644 index 00000000..33654dc3 --- /dev/null +++ b/src/webgl/renderer.js @@ -0,0 +1,982 @@ + + +(function($) { + + +/** + * Wrapping the funcionality of WebGL to be suitable for tile processing and rendering. + * Written by Aiosa + * @class OpenSeadragon.WebGLModule + * @memberOf OpenSeadragon + */ +$.WebGLModule = class extends $.EventSource { + /** + * @typedef {{ + * name: string, + * lossless: boolean, + * shaders: Object. + * }} OpenSeadragon.WebGLModule.RenderingConfig + * + * //use_channel[X] name + * @template {Object} TUseChannel + * //use_[fitler_name] + * @template {Object} TUseFilter + * @template {Object} TIControlConfig + * @typedef OpenSeadragon.WebGLModule.ShaderLayerParams + * @type {{TUseChannel,TUseFilter,TIControlConfig}} + * + * @typedef {{ + * name: string, + * type: string, + * visible: boolean, + * dataReferences: number[], + * params: OpenSeadragon.WebGLModule.ShaderLayerParams + * }} OpenSeadragon.WebGLModule.ShaderLayerConfig + * + * + * @typedef OpenSeadragon.WebGLModule.UIControlsRenderer + * @type function + * @param {string} title + * @param {string} html + * @param {string} dataId + * @param {boolean} isVisible + * @param {OpenSeadragon.WebGLModule.ShaderLayer} layer + * @param {boolean} wasErrorWhenLoading + */ + + + /** + * @param {object} incomingOptions + * @param {string} incomingOptions.htmlControlsId: where to render html controls, + * @param {string} incomingOptions.webGlPreferredVersion prefered WebGL version, for now "1.0" or "2.0" + * @param {OpenSeadragon.WebGLModule.UIControlsRenderer} incomingOptions.htmlShaderPartHeader function that generates particular layer HTML + * @param {boolean} incomingOptions.debug debug mode default false + * @param {function} incomingOptions.ready function called when ready + * @param {function} incomingOptions.resetCallback function called when user input changed, e.g. changed output of the current rendering + * @param {function} incomingOptions.visualisationInUse function called when a specification is initialized and run + * @param {function} incomingOptions.visualisationChanged function called when a visualization swap is performed: + * signature f({Visualization} oldVisualisation,{Visualization} newVisualisation) + * @constructor + * @memberOf OpenSeadragon.WebGLModule + */ + constructor(incomingOptions) { + super(); + + ///////////////////////////////////////////////////////////////////////////////// + ///////////// Default values overrideable from incomingOptions ///////////////// + ///////////////////////////////////////////////////////////////////////////////// + this.uniqueId = ""; + + //todo events instead + this.ready = function() { }; + this.htmlControlsId = null; + this.webGlPreferredVersion = "2.0"; + this.htmlShaderPartHeader = function(title, html, dataId, isVisible, layer, isControllable = true) { + return `
${title}
${html}
`; + }; + this.resetCallback = function() { }; + //called once a visualisation is compiled and linked (might not happen) + this.visualisationReady = function(i, visualisation) { }; + //called once a visualisation is switched to (including first run) + this.visualisationInUse = function(visualisation) { }; + this.visualisationChanged = function(oldVis, newVis) { }; + + + /** + * Debug mode. + * @member {boolean} + */ + this.debug = false; + + ///////////////////////////////////////////////////////////////////////////////// + ///////////// Incoming Values /////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////// + + // Assign from incoming terms + for (let key in incomingOptions) { + if (incomingOptions[key]) { + this[key] = incomingOptions[key]; + } + } + + if (!this.constructor.idPattern.test(this.uniqueId)) { + throw "$.WebGLModule: invalid ID! Id can contain only letters, numbers and underscore. ID: " + this.uniqueId; + } + + /** + * Current rendering context + * @member {OpenSeadragon.WebGLModule.WebGLImplementation} + */ + this.webglContext = null; + + /** + * WebGL context + * @member {WebGLRenderingContextBase} + */ + this.gl = null; + + ///////////////////////////////////////////////////////////////////////////////// + ///////////// Internals ///////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////////////// + + this.reset(); + + try { + const canvas = document.createElement("canvas"); + for (let version of [this.webGlPreferredVersion, "2.0", "1.0"]) { + const Context = $.WebGLModule.determineContext(version); + let glContext = Context && Context.create(canvas); + + if (glContext) { + this.gl = glContext; + const contextOpts = incomingOptions[version] || {}; + const readGlProp = function(prop, defaultValue) { + return glContext[contextOpts[prop] || defaultValue] || glContext[defaultValue]; + }; + + /** + * @param {object} options + * @param {string} options.wrap texture wrap parameteri + * @param {string} options.magFilter texture filter parameteri + * @param {string} options.minFilter texture filter parameteri + * @param {string|WebGLModule.IDataLoader} options.dataLoader class name or implementation of a given loader + */ + const options = { + wrap: readGlProp("wrap", "MIRRORED_REPEAT"), + magFilter: readGlProp("magFilter", "LINEAR"), + minFilter: readGlProp("minFilter", "NEAREST"), + dataLoader: contextOpts.dataLoader || "TEXTURE_2D" + }; + this.webglContext = new Context(this, glContext, options); + } + } + + } catch (e) { + /** + * @event fatal-error + */ + this.raiseEvent('fatal-error', {message: "Unable to initialize the WebGL renderer.", + details: e}); + console.error(e); + return; + } + console.log(`WebGL ${this.webglContext.getVersion()} Rendering module (ID ${this.uniqueId})`); + } + + /** + * Reset the engine to the initial state + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + reset() { + this._unloadCurrentProgram(); + this._programConfigurations = []; + this._dataSources = []; + this._shaderDataIndexToGlobalDataIndex = []; + this._origDataSources = []; + this._programs = {}; + this._program = -1; + this._prepared = false; + this.running = false; + this._initialized = false; + } + + /** + * Check if prepare() was called. + * @return {boolean} + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + get isPrepared() { + return this._prepared; + } + + /** + * WebGL target canvas + * @return {HTMLCanvasElement} + */ + get canvas() { + return this.gl.canvas; + } + + /** + * WebGL active program + * @return {WebGLProgram} + */ + get program() { + return this._programs[this._program]; + } + + /** + * Check if init() was called. + * @return {boolean} + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + get isInitialized() { + return this._initialized; + } + + /** + * Change the dimensions, useful for borders, used by openSeadragonGL + * @instance + * @memberOf WebGLModule + */ + setDimensions(x, y, width, height) { + if (width === this.width && height === this.height) { + return; + } + + this.width = width; + this.height = height; + this.gl.canvas.width = width; + this.gl.canvas.height = height; + this.gl.viewport(x, y, width, height); + } + + /** + * Set program shaders. Vertex shader is set by default a square. + * @param {RenderingConfig} configurations - objects that define the what to render (see Readme) + * @return {boolean} true if loaded successfully + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + addRenderingSpecifications(...configurations) { + if (this._prepared) { + console.error("New specification cannot be introduced after the visualiser was prepared."); + return false; + } + for (let config of configurations) { + if (!config.shaders) { + console.warn("Invalid visualization: no shaders defined", config); + continue; + } + + let count = 0; + for (let sid in config.shaders) { + const shader = config.shaders[sid]; + if (!shader.params) { + shader.params = {}; + } + count++; + } + + if (count < 0) { + console.warn("Invalid configualization: no shader configuration present!", config); + continue; + } + this._programConfigurations.push(config); + } + return true; + } + + /** + * Runs a callback on each specification + * @param {function} call callback to perform on each specification (its object given as the only parameter) + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + foreachRenderingSpecification(call) { + this._programConfigurations.forEach(vis => call(vis)); + } + + /** + * Rebuild specification and update scene + * @param {string[]|undefined} order of shaders, ID's of data as defined in setup JSON, last element + * is rendered last (top) + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + rebuildSpecification(order = undefined) { + let vis = this._programConfigurations[this._program]; + + if (order) { + vis.order = order; + } + this._unloadCurrentProgram(); + this._specificationToProgram(vis, this._program); + this._forceSwitchShader(this._program); + } + + /** + * Get currently used specification + * @return {object} current specification + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + specification(index) { + return this._programConfigurations[Math.min(index, this._programConfigurations.length - 1)]; + } + + /** + * Get currently used specification ilayer.params,ndex + * @return {number} index of the current specification + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + currentSpecificationIndex() { + return this._program; + } + + /** + * Switch to program at index: this is the index (order) in which + * setShaders(...) was called. If you want to switch to shader that + * has been set with second setShaders(...) call, pass i=1. + * @param {Number} i program index or null if you wish to re-initialize the current one + * @instance + * @memberOf OpenSeadragon.WebGLModule + */ + useSpecification(i) { + if (!this._initialized) { + console.warn("$.WebGLModule::useSpecification(): not initialized."); + return; + } + if (this._program === i) { + return; + } + let oldIndex = this._program; + this._forceSwitchShader(i); + this.visualisationChanged(this._programConfigurations[oldIndex], this._programConfigurations[i]); + } + + /** + * Get a list of image pyramids used to compose the current active specification + * @instance + * @memberOf WebGLModule + */ + getSources() { + //return this._programConfigurations[this._program].dziExtendedUrl; + return this._dataSources; + } + + /** + * Supported: 'wrap', 'minFilter', 'magFilter' + * @param {string} name WebGL name of the parameter + * @param {GLuint} value + */ + setTextureParam(name, value) { + this.webglContext.texture.setTextureParam(name, value); + } + + /** + * @param id + * @param data + * @param width + * @param height + */ + loadData(id, data, width, height) { + this.webglContext.texture.load(this, id, data, width, height); + } + + /** + * Renders data using WebGL + * @param {string} id used in loadImage() + * + * @param {object} tileOpts + * @param {number} tileOpts.zoom value passed to the shaders as zoom_level + * @param {number} tileOpts.pixelSize value passed to the shaders as pixel_size_in_fragments + * @param {OpenSeadragon.Mat3} tileOpts.transform position of the rendered tile + * + * @instance + * @memberOf WebGLModule + */ + processData(id, tileOpts) { + this.webglContext.programUsed( + this.program, + this._programConfigurations[this._program], + id, + tileOpts + ); + + // if (this.debug) { + // //todo + // this._renderDebugIO(data, result); + // } + } + + freeData(id) { + + } + + /** + * Clear the output canvas + */ + clear() { + //todo: necessary? + this.gl.clearColor(0, 0, 0, 0); + this.gl.clear(this.gl.COLOR_BUFFER_BIT); + } + + /** + * Whether the webgl module renders UI + * @return {boolean|boolean} + * @instance + * @memberOf WebGLModule + */ + supportsHtmlControls() { + return typeof this.htmlControlsId === "string" && this.htmlControlsId.length > 0; + } + + /** + * Execute call on each visualization layer with no errors + * @param {object} vis current specification setup context + * @param {function} callback call to execute + * @param {function} onFail handle exception during execition + * @return {boolean} true if no exception occured + * @instance + * @memberOf WebGLModule + */ + static eachValidShaderLayer(vis, callback, + onFail = (layer, e) => { + layer.error = e.message; + console.error(e); + }) { + let shaders = vis.shaders; + let noError = true; + for (let key in shaders) { + let shader = shaders[key]; + + if (shader && !shader.error) { + try { + callback(shader); + } catch (e) { + if (!onFail) { + throw e; + } + onFail(shader, e); + noError = false; + } + } + } + return noError; + } + + /** + * Execute call on each _visible_ specification layer with no errors. + * Visible is subset of valid. + * @param {object} vis current specification setup context + * @param {function} callback call to execute + * @param {function} onFail handle exception during execition + * @return {boolean} true if no exception occured + * @instance + * @memberOf WebGLModule + */ + static eachVisibleShaderLayer(vis, callback, + onFail = (layer, e) => { + layer.error = e.message; + console.error(e); + }) { + + let shaders = vis.shaders; + let noError = true; + for (let key in shaders) { + //rendering == true means no error + let shader = shaders[key]; + if (shader && shader.rendering) { + try { + callback(shader); + } catch (e) { + if (!onFail) { + throw e; + } + onFail(shader, e); + noError = false; + } + } + } + return noError; + } + + ///////////////////////////////////////////////////////////////////////////////////// + //// YOU PROBABLY WANT TO READ FUNCTIONS BELOW SO YOU KNOW HOW TO SET UP YOUR SHADERS + //// BUT YOU SHOULD NOT CALL THEM DIRECTLY + ///////////////////////////////////////////////////////////////////////////////////// + + /** + * Get current program, reset if invalid + * @return {number} program index + */ + getCurrentProgramIndex() { + if (this._program < 0 || this._program >= this._programConfigurations.length) { + this._program = 0; + } + return this._program; + } + + /** + * Function to JSON.stringify replacer + * @param key key to the value + * @param value value to be exported + * @return {*} value if key passes exportable condition, undefined otherwise + */ + static jsonReplacer(key, value) { + return key.startsWith("_") || ["eventSource"].includes(key) ? undefined : value; + } + + /** + * For easy initialization, do both in once call. + * For separate initialization (prepare|init), see functions below. + * @param {string[]|undefined} dataSources a list of data identifiers available to the specifications + * - specification configurations should not reference data not present in this array + * - the module gives you current list of required subset of this list for particular active visualization goal + * @param width initialization width + * @param height initialization height + */ + prepareAndInit(dataSources = undefined, width = 1, height = 1) { + this.prepare(dataSources); + this.init(width, height); + } + + /** + * Prepares the WebGL wrapper for being initialized. It is separated from + * initialization as this must be finished before OSD is ready (we must be ready to draw when the data comes). + * The idea is to open the protocol for OSD in onPrepared. + * Shaders are fetched from `specification.url` parameter. + * + * @param {string[]|undefined} dataSources id's of data such that server can understand which image to send (usually paths) + * @param {number} visIndex index of the initial specification + */ + prepare(dataSources = undefined, visIndex = 0) { + if (this._prepared) { + console.error("Already prepared!"); + return; + } + + if (this._programConfigurations.length < 1) { + console.error("No specification specified!"); + /** + * @event fatal-error + */ + this.raiseEvent('fatal-error', {message: "No specification specified!", + details: "::prepare() called with no specification set."}); + return; + } + this._origDataSources = dataSources || []; + this._program = visIndex; + + this._prepared = true; + this.getCurrentProgramIndex(); //resets index + this._specificationToProgram(this._programConfigurations[this._program], this._program); + } + + /** + * Initialization. It is separated from preparation as this actually initiates the rendering, + * sometimes this can happen only when other things are ready. Must be performed after + * all the prepare() strategy finished: e.g. as onPrepared. Or use prepareAndInit(); + * + * @param {int} width width of the first tile going to be drawn + * @param {int} height height of the first tile going to be drawn + */ + init(width = 1, height = 1) { + if (!this._prepared) { + console.error("The viaGL was not yet prepared. Call prepare() before init()!"); + return; + } + if (this._initialized) { + console.error("Already initialized!"); + return; + } + this._initialized = true; + this.setDimensions(width, height); + + + //todo rotate anticlockwise to cull backfaces + this.gl.enable(this.gl.CULL_FACE); + this.gl.cullFace(this.gl.FRONT); + + this.running = true; + + this._forceSwitchShader(null); + this.ready(); + } + + setDataBlendingEnabled(enabled) { + if (enabled) { + this.gl.enable(this.gl.BLEND); + this.gl.blendEquation(this.gl.FUNC_ADD); + this.gl.blendFuncSeparate(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA, this.gl.ONE, this.gl.ONE); + } else { + this.gl.disable(this.gl.BLEND); + } + } + + /** + * Supported are two modes: show and blend + * show is the default option, stacking layers by generalized alpha blending + * blend is a custom alternative, default is a mask (remove background where foreground.a > 0.001) + * + * vec4 my_blend(vec4 foreground, vec4 background) { + * <> //here goes your blending code + * } + * + * @param code GLSL code to blend - must return vec4() and can use + * two variables: background, foreground + */ + setLayerBlending(code) { + this.webglContext.setBlendEquation(code); + this.rebuildSpecification(); + } + + ////////////////////////////////////////////////////////////////////////////// + ///////////// YOU PROBABLY DON'T WANT TO READ/CHANGE FUNCTIONS BELOW + ////////////////////////////////////////////////////////////////////////////// + + /** + * Forward glLoaded event to the active layer + * @param gl + * @param program + * @param vis + */ + glLoaded(gl, program, vis) { + $.WebGLModule.eachVisibleShaderLayer(vis, layer => layer._renderContext.glLoaded(program, gl)); + } + + /** + * Forward glDrawing event to the active layer + * @param gl + * @param program + * @param vis + * @param bounds + */ + glDrawing(gl, program, vis, bounds) { + $.WebGLModule.eachVisibleShaderLayer(vis, layer => layer._renderContext.glDrawing(program, gl)); + } + + /** + * Force switch shader (program), will reset even if the specified + * program is currently active, good if you need 'gl-loaded' to be + * invoked (e.g. some uniform variables changed) + * @param {Number} i program index or null if you wish to re-initialize the current one + * @param _reset + * @private + */ + _forceSwitchShader(i, _reset = true) { + if (isNaN(i) || i === null || i === undefined) { + i = this._program; + } + + if (i >= this._programConfigurations.length) { + console.error("Invalid specification index ", i, "trying to use index 0..."); + if (i === 0) { + return; + } + i = 0; + } + + let target = this._programConfigurations[i]; + if (!this._programs[i]) { + this._specificationToProgram(target, i); + } else if (i !== this._program) { + this._updateRequiredDataSources(target); + } + + this._program = i; + if (target.error) { + if (this.supportsHtmlControls()) { + this._loadHtml(i, this._program); + } + this._loadScript(i, this._program); + this.running = false; + if (this._programConfigurations.length < 2) { + /** + * @event fatal-error + */ + this.raiseEvent('fatal-error', {message: "The only rendering specification left is invalid!", target: target}); + } else { + /** + * @event error + */ + this.raiseEvent('error', {message: "Currently chosen rendering specification is not valid!", target: target}); + } + } else { + this.running = true; + if (this.supportsHtmlControls()) { + this._loadHtml(i, this._program); + } + this._loadDebugInfo(); + if (!this._loadScript(i, this._program)) { + if (!_reset) { + throw "Could not build visualization"; + } + this._forceSwitchShader(i, false); //force reset in errors + return; + } + this.webglContext.programLoaded(this._programs[i], target); + } + } + + _unloadCurrentProgram() { + let program = this._programs && this._programs[this._program]; + if (program) { + //must remove before attaching new + this._detachShader(program, "VERTEX_SHADER"); + this._detachShader(program, "FRAGMENT_SHADER"); + } + } + + _loadHtml(visId) { + let htmlControls = document.getElementById(this.htmlControlsId); + htmlControls.innerHTML = this._programConfigurations[visId]._built["html"]; + } + + _loadScript(visId) { + return $.WebGLModule.eachValidShaderLayer(this._programConfigurations[visId], layer => layer._renderContext.init()); + } + + _getDebugInfoPanel() { + return `
+WebGL Processing I/O (debug mode) +
+Input:
No input.

+Output:
No output.
`; + } + + _loadDebugInfo() { + if (!this.debug) { + return; + } + + let container = document.getElementById(`test-${this.uniqueId}-webgl`); + if (!container) { + if (!this.htmlControlsId) { + document.body.innerHTML += `
${this._getDebugInfoPanel()}
`; + } else { + //safe as we do this before handlers are attached + document.getElementById(this.htmlControlsId).parentElement.innerHTML += `
${this._getDebugInfoPanel()}
`; + } + } + } + + _renderDebugIO(inputData, outputData) { + let input = document.getElementById(`test-${this.uniqueId}-webgl-input`); + let output = document.getElementById(`test-${this.uniqueId}-webgl-output`); + + input.innerHTML = ""; + input.append($.WebGLModule.Loaders.dataAsHtmlElement(inputData)); + + if (outputData) { + output.innerHTML = ""; + if (!this._ocanvas) { + this._ocanvas = document.createElement("canvas"); + } + this._ocanvas.width = outputData.width; + this._ocanvas.height = outputData.height; + let octx = this._ocanvas.getContext('2d'); + octx.drawImage(outputData, 0, 0); + output.append(this._ocanvas); + } else { + output.innerHTML = "No output!"; + } + } + + _buildFailed(specification, error) { + console.error(error); + specification.error = "Failed to compose this specification."; + specification.desc = error; + } + + _buildSpecification(order, specification) { + try { + let data = this.webglContext.compileSpecification(order, specification, + this._shaderDataIndexToGlobalDataIndex, this.supportsHtmlControls()); + + if (data.usableShaders < 1) { + this._buildFailed(specification, `Empty specification: no valid specification has been specified. +
Specification setup:
${JSON.stringify(specification, $.WebGLModule.jsonReplacer)} +
Dynamic shader data:
${JSON.stringify(specification.data)}`); + return null; + } + data.dziExtendedUrl = data.dataUrls.join(","); + specification._built = data; + + //preventive + delete specification.error; + delete specification.desc; + return data; + } catch (error) { + this._buildFailed(specification, error); + } + return null; + } + + _detachShader(program, type) { + let shader = program[type]; + if (shader) { + this.gl.detachShader(program, shader); + this.gl.deleteShader(shader); + program[type] = null; + } + } + + _specificationToProgram(vis, idx) { + if (!vis._built) { + vis._built = {}; + } + + this._updateRequiredDataSources(vis); + this._processSpecification(vis, idx); + return idx; + } + + _initializeShaderFactory(ShaderFactoryClass, layer, idx) { + if (!ShaderFactoryClass) { + layer.error = "Unknown layer type."; + layer.desc = `The layer type '${layer.type}' has no associated factory. Missing in 'shaderSources'.`; + console.warn("Skipping layer " + layer.name); + return; + } + layer._index = idx; + layer.visible = layer.visible === undefined ? true : layer.visible; + layer._renderContext = new ShaderFactoryClass(`${this.uniqueId}${idx}`, layer.params || {}, { + layer: layer, + webgl: this.webglContext, + invalidate: this.resetCallback, + rebuild: this.rebuildSpecification.bind(this, undefined) + }); + } + + _updateRequiredDataSources(specs) { + //for now just request all data, later decide in the context on what to really send + //might in the future decide to only request used data, now not supported + let usedIds = new Set(); + for (let key in specs.shaders) { + let layer = specs.shaders[key]; + if (layer) { + for (let x of layer.dataReferences) { + usedIds.add(x); + } + } + } + usedIds = [...usedIds].sort(); + this._dataSources = []; + + while (usedIds[usedIds.length - 1] >= this._origDataSources.length) { + //make sure values are set if user did not provide + this._origDataSources.push("__generated_do_not_use__"); + } + + this._shaderDataIndexToGlobalDataIndex = new Array( + Math.max(this._origDataSources.length, usedIds[usedIds.length - 1]) + ).fill(-1); + + for (let id of usedIds) { + this._shaderDataIndexToGlobalDataIndex[id] = this._dataSources.length; + this._dataSources.push(this._origDataSources[id]); + while (id > this._shaderDataIndexToGlobalDataIndex.length) { + this._shaderDataIndexToGlobalDataIndex.push(-1); + } + } + } + + _processSpecification(spec, idx) { + let gl = this.gl, + err = function(message, description) { + spec.error = message; + spec.desc = description; + }; + + let program; + + if (!this._programs[idx]) { + program = gl.createProgram(); + this._programs[idx] = program; + + let index = 0; + //init shader factories and unique id's + for (let key in spec.shaders) { + let layer = spec.shaders[key]; + if (layer) { + let ShaderFactoryClass = $.WebGLModule.ShaderMediator.getClass(layer.type); + if (layer.type === "none") { + continue; + } + this._initializeShaderFactory(ShaderFactoryClass, layer, index++); + } + } + } else { + program = this._programs[idx]; + for (let key in spec.shaders) { + let layer = spec.shaders[key]; + + if (layer) { + if (!layer.error && + layer._renderContext && + layer._renderContext.constructor.type() === layer.type) { + continue; + } + delete layer.error; + delete layer.desc; + if (layer.type === "none") { + continue; + } + let ShaderFactoryClass = $.WebGLModule.ShaderMediator.getClass(layer.type); + this._initializeShaderFactory(ShaderFactoryClass, layer, layer._index); + } + } + } + + if (!Array.isArray(spec.order) || spec.order.length < 1) { + spec.order = Object.keys(spec.shaders); + } + + this._buildSpecification(spec.order, spec); + + if (spec.error) { + this.visualisationReady(idx, spec); + return; + } + + this.constructor.compileShader(gl, program, + spec._built.vertexShader, spec._built.fragmentShader, err, this.debug); + this.visualisationReady(idx, spec); + } + + static compileShader(gl, program, VS, FS, onError, isDebugMode) { + function ok (kind, status, value, sh) { + if (!gl['get' + kind + 'Parameter'](value, gl[status + '_STATUS'])) { + console.error((sh || 'LINK') + ':\n' + gl['get' + kind + 'InfoLog'](value)); + return false; + } + return true; + } + + function useShader(gl, program, data, type) { + let shader = gl.createShader(gl[type]); + gl.shaderSource(shader, data); + gl.compileShader(shader); + gl.attachShader(program, shader); + program[type] = shader; + return ok('Shader', 'COMPILE', shader, type); + } + + function numberLines(str) { + //https://stackoverflow.com/questions/49714971/how-to-add-line-numbers-to-beginning-of-each-line-in-string-in-javascript + return str.split('\n').map((line, index) => `${index + 1} ${line}`).join('\n'); + } + + if (!useShader(gl, program, VS, 'VERTEX_SHADER') || + !useShader(gl, program, FS, 'FRAGMENT_SHADER')) { + onError("Unable to use this specification.", + "Compilation of shader failed. For more information, see logs in the console."); + console.warn("VERTEX SHADER\n", numberLines( VS )); + console.warn("FRAGMENT SHADER\n", numberLines( FS )); + } else { + gl.linkProgram(program); + if (!ok('Program', 'LINK', program)) { + onError("Unable to use this specification.", + "Linking of shader failed. For more information, see logs in the console."); + } else { //if (isDebugMode) { //todo testing + console.info("FRAGMENT SHADER\n", numberLines( FS )); + } + } + } +}; + +/** + * ID pattern allowed for module, ID's are used in GLSL + * to distinguish uniquely between static generated code parts + * @type {RegExp} + */ +$.WebGLModule.idPattern = /[0-9a-zA-Z_]*/; + +})(OpenSeadragon); diff --git a/src/webgl/shaderLayer.js b/src/webgl/shaderLayer.js new file mode 100644 index 00000000..23ffd4eb --- /dev/null +++ b/src/webgl/shaderLayer.js @@ -0,0 +1,1305 @@ +(function($) { + + /** + * Shader sharing point + * @class OpenSeadragon.WebGLModule.ShaderMediator + */ +$.WebGLModule.ShaderMediator = class { + + /** + * Register shader + * @param {function} LayerRendererClass class extends OpenSeadragon.WebGLModule.ShaderLayer + */ + static registerLayer(LayerRendererClass) { + //todo why not hasOwnProperty check allowed by syntax checker + // if (this._layers.hasOwnProperty(LayerRendererClass.type())) { + // console.warn("Registering an already existing layer renderer:", LayerRendererClass.type()); + // } + // if (!$.WebGLModule.ShaderLayer.isPrototypeOf(LayerRendererClass)) { + // throw `${LayerRendererClass} does not inherit from ShaderLayer!`; + // } + this._layers[LayerRendererClass.type()] = LayerRendererClass; + } + + /** + * Get the shader class by type id + * @param {string} id + * @return {function} class extends OpenSeadragon.WebGLModule.ShaderLayer + */ + static getClass(id) { + return this._layers[id]; + } + + /** + * Get all available shaders + * @return {function[]} classes that extend OpenSeadragon.WebGLModule.ShaderLayer + */ + static availableShaders() { + return Object.values(this._layers); + } +}; +//todo why cannot be inside object :/ +$.WebGLModule.ShaderMediator._layers = {}; + + +/** + * Abstract interface to any Shader. + * @abstract + */ +$.WebGLModule.ShaderLayer = class { + + /** + * Override **static** type definition + * The class must be registered using the type + * @returns {string} unique id under which is the shader registered + */ + static type() { + throw "ShaderLayer::type() Type must be specified!"; + } + + /** + * Override **static** name definition + * @returns {string} name of the shader (user-friendly) + */ + static name() { + throw "ShaderLayer::name() Name must be specified!"; + } + + /** + * Provide description + * @returns {string} optional description + */ + static description() { + return "ShaderLayer::description() WebGL shader must provide description."; + } + + /** + * Declare the number of data sources it reads from (how many dataSources indexes should the shader contain) + * @return {Array.} array of source specifications: + * acceptsChannelCount: predicate that evaluates whether given number of channels (argument) is acceptable + * [optional] description: the description of the source - what it is being used for + */ + static sources() { + throw "ShaderLayer::sources() Shader must specify channel acceptance predicates for each source it uses!"; + } + + /** + * Global supported options + * @param {string} id unique ID among all webgl instances and shaders + * @param {OpenSeadragon.WebGLModule.ShaderLayerParams} options + * options.channel: "r", "g" or "b" channel to sample, default "r" + * options.use_mode: blending mode - default alpha ("show"), custom blending ("mask") and clipping mask blend ("mask_clip") + * options.use_[*]: filtering, gamma/exposure/logscale with a float filter parameter (e.g. "use_gamma" : 1.5) + * @param {object} privateOptions options that should not be touched, necessary for linking the layer to the core + */ + constructor(id, options, privateOptions) { + this.uid = id; + this._setContextShaderLayer(privateOptions.layer); + this.webglContext = privateOptions.webgl; + this.invalidate = privateOptions.invalidate; + //use with care... + this._rebuild = privateOptions.rebuild; + + this._buildControls(options); + this.resetChannel(options); + this.resetMode(options); + } + + /** + * Code placed outside fragment shader's main(...). + * By default, it includes all definitions of + * controls you defined in defaultControls + * + * NOTE THAT ANY VARIABLE NAME + * WITHIN THE GLOBAL SPACE MUST BE + * ESCAPED WITH UNIQUE ID: this.uid + * + * DO NOT SAMPLE TEXTURE MANUALLY: use this.sampleChannel(...) to generate the code + * + * WHEN OVERRIDING, INCLUDE THE OUTPUT OF THIS METHOD AT THE BEGINNING OF THE NEW OUTPUT. + * + * @return {string} + */ + getFragmentShaderDefinition() { + let controls = this.constructor.defaultControls, + html = []; + for (let control in controls) { + if (control.startsWith("use_")) { + continue; + } + + const controlObject = this[control]; + if (controlObject) { + let code = controlObject.define(); + if (code) { + code = code.trim(); + html.push(code); + } + } + } + return html.join("\n"); + } + + /** + * Code executed to create the output color. The code + * must always return a vec4 value, otherwise the visualization + * will fail to compile (this code actually runs inside a vec4 function). + * + * DO NOT SAMPLE TEXTURE MANUALLY: use this.sampleChannel(...) to generate the code + * + * @return {string} + */ + getFragmentShaderExecution() { + throw "ShaderLayer::getFragmentShaderExecution must be implemented!"; + } + + /** + * Called when an image is rendered + * @param {WebGLProgram} program WebglProgram instance + * @param {WebGLRenderingContextBase} gl + */ + glDrawing(program, gl) { + let controls = this.constructor.defaultControls; + for (let control in controls) { + if (control.startsWith("use_")) { + continue; + } + + const controlObject = this[control]; + if (controlObject) { + controlObject.glDrawing(program, gl); + } + } + } + + /** + * Called when associated webgl program is switched to + * @param {WebGLProgram} program WebglProgram instance + * @param {WebGLRenderingContextBase} gl WebGL Context + */ + glLoaded(program, gl) { + let controls = this.constructor.defaultControls; + for (let control in controls) { + if (control.startsWith("use_")) { + continue; + } + + const controlObject = this[control]; + if (controlObject) { + controlObject.glLoaded(program, gl); + } + } + } + + /** + * This function is called once at + * the beginning of the layer use + * (might be multiple times), after htmlControls() + */ + init() { + let controls = this.constructor.defaultControls; + for (let control in controls) { + if (control.startsWith("use_")) { + continue; + } + + const controlObject = this[control]; + if (controlObject) { + controlObject.init(); + } + } + } + + /** + * Get the shader UI controls + * @return {string} HTML controls for the particular shader + */ + htmlControls() { + let controls = this.constructor.defaultControls, + html = []; + for (let control in controls) { + if (control.startsWith("use_")) { + continue; + } + + const controlObject = this[control]; + if (controlObject) { + html.push(controlObject.toHtml(true)); + } + } + return html.join(""); + } + + /** + * Include GLSL shader code on global scope + * (e.g. define function that is repeatedly used) + * does not have to use unique ID extended names as this code is included only once + * @param {string} key a key under which is the code stored, so that the same key is not loaded twice + * @param {string} code GLSL code to add to the shader + */ + includeGlobalCode(key, code) { + let container = this.constructor.__globalIncludes; + if (!container[key]) { + container[key] = code; + } + } + + /** + * Parses value to a float string representation with given precision (length after decimal) + * @param {number} value value to convert + * @param {number} defaultValue default value on failure + * @param {number} precisionLen number of decimals + * @return {string} + */ + toShaderFloatString(value, defaultValue, precisionLen = 5) { + return this.constructor.toShaderFloatString(value, defaultValue, precisionLen); + } + + /** + * Parses value to a float string representation with given precision (length after decimal) + * @param {number} value value to convert + * @param {number} defaultValue default value on failure + * @param {number} precisionLen number of decimals + * @return {string} + */ + static toShaderFloatString(value, defaultValue, precisionLen = 5) { + if (!Number.isInteger(precisionLen) || precisionLen < 0 || precisionLen > 9) { + precisionLen = 5; + } + try { + return value.toFixed(precisionLen); + } catch (e) { + return defaultValue.toFixed(precisionLen); + } + } + + /** + * Sample only one channel (which is defined in options) + * @param {string} textureCoords valid GLSL vec2 object as string + * @param {number} otherDataIndex index of the data in self.dataReference JSON array + * @param {boolean} raw whether to output raw value from the texture (do not apply filters) + * @return {string} code for appropriate texture sampling within the shader, + * where only one channel is extracted or float with zero value if + * the reference is not valid + */ + sampleChannel(textureCoords, otherDataIndex = 0, raw = false) { + let refs = this.__visualisationLayer.dataReferences; + const chan = this.__channels[otherDataIndex]; + + if (otherDataIndex >= refs.length) { + switch (chan.length) { + case 1: return ".0"; + case 2: return "vec2(.0)"; + case 3: return "vec3(.0)"; + default: + return 'vec4(0.0)'; + } + } + let sampled = `${this.webglContext.texture.sample(refs[otherDataIndex], textureCoords)}.${chan}`; + // if (raw) return sampled; + // return this.filter(sampled); + return sampled; + } + + /** + * For error detection, how many textures are available + * @return {number} number of textures available + */ + dataSourcesCount() { + return this.__visualisationLayer.dataReferences.length; + } + + /** + * Load value, useful for controls value caching + * @param {string} name value name + * @param {string} defaultValue default value if no stored value available + * @return {string} stored value or default value + */ + loadProperty(name, defaultValue) { + let selfType = this.constructor.type(); + if (!this.__visualisationLayer) { + return defaultValue; + } + + const value = this.__visualisationLayer.cache[selfType][name]; + return value === undefined ? defaultValue : value; + } + + /** + * Store value, useful for controls value caching + * @param {string} name value name + * @param {*} value value + */ + storeProperty(name, value) { + this.__visualisationLayer.cache[this.constructor.type()][name] = value; + } + + /** + * Evaluates option flag, e.g. any value that indicates boolean 'true' + * @param {*} value value to interpret + * @return {boolean} true if the value is considered boolean 'true' + */ + isFlag(value) { + return value === "1" || value === true || value === "true"; + } + + isFlagOrMissing(value) { + return value === undefined || this.isFlag(value); + } + + /** + * Get the mode we operate in + * @return {string} mode + */ + get mode() { + return this._mode; + } + + /** + * Returns number of textures available to this shader + * @return {number} number of textures available + */ + get texturesCount() { + return this.__visualisationLayer.dataReferences.length; + } + + /** + * Set sampling channel + * @param {object} options + * @param {string} options.use_channel[X] chanel swizzling definition to sample + */ + resetChannel(options) { + const parseChannel = (name, def, sourceDef) => { + const predefined = this.constructor.defaultControls[name]; + + if (options[name] || predefined) { + let channel = predefined ? (predefined.required ? predefined.required : predefined.default) : undefined; + if (!channel) { + channel = this.loadProperty(name, options[name]); + } + + if (!channel || typeof channel !== "string" || this.constructor.__chanPattern.exec(channel) === null) { + console.warn(`Invalid channel '${name}'. Will use channel '${def}'.`, channel, options); + this.storeProperty(name, "r"); + channel = def; + } + + if (!sourceDef.acceptsChannelCount(channel.length)) { + throw `${this.constructor.name()} does not support channel length for channel: ${channel}`; + } + + if (channel !== options[name]) { + this.storeProperty(name, channel); + } + return channel; + } + return def; + }; + this.__channels = this.constructor.sources().map((source, i) => parseChannel(`use_channel${i}`, "r", source)); + } + + /** + * Set blending mode + * @param {object} options + * @param {string} options.use_mode blending mode to use: "show" or "mask" + */ + resetMode(options) { + const predefined = this.constructor.defaultControls.use_mode; + if (options["use_mode"]) { + this._mode = predefined && predefined.required; + if (!this._mode) { + this._mode = this.loadProperty("use_mode", options.use_mode); + } + + if (this._mode !== options.use_mode) { + this.storeProperty("use_mode", this._mode); + } + } else { + this._mode = predefined ? (predefined.default || "show") : "show"; + } + + this.__mode = this.constructor.modes[this._mode] || "show"; + } + + //////////////////////////////////// + ////////// PRIVATE ///////////////// + //////////////////////////////////// + + + _buildControls(options) { + let controls = this.constructor.defaultControls; + + if (controls.opacity === undefined || (typeof controls.opacity === "object" && !controls.opacity.accepts("float"))) { + controls.opacity = { + default: {type: "range", default: 1, min: 0, max: 1, step: 0.1, title: "Opacity: "}, + accepts: (type, instance) => type === "float" + }; + } + + for (let control in controls) { + let buildContext = controls[control]; + + if (buildContext) { + if (control.startsWith("use_")) { + continue; + } + + this[control] = $.WebGLModule.UIControls.build(this, control, options[control], + buildContext.default, buildContext.accepts, buildContext.required); + } + } + } + + _setContextShaderLayer(visualisationLayer) { + this.__visualisationLayer = visualisationLayer; + if (!this.__visualisationLayer.cache) { + this.__visualisationLayer.cache = {}; + } + if (!this.__visualisationLayer.cache[this.constructor.type()]) { + this.__visualisationLayer.cache[this.constructor.type()] = {}; + } + } +}; + +/** + * Declare supported controls by a particular shader + * each controls is automatically created for the shader + * and this[controlId] instance set + * structure: + * { + * controlId: { + default: {type: <>, title: <>, interactive: true|false...}, + accepts: (type, instance) => <>, + required: {type: <> ...} [OPTIONAL] + * }, ... + * } + * + * use: controlId: false to disable a specific control (e.g. all shaders + * support opacity by default - use to remove this feature) + * + * + * Additionally, use_[...] value can be specified, such controls enable shader + * to specify default or required values for built-in use_[...] params. example: + * { + * use_channel0: { + * default: "bg" + * }, + * use_channel1: { + * required: "rg" + * }, + * use_gamma: { + * default: 0.5 + * } + * } + * reads by default for texture 1 channels 'bg', second texture is always forced to read 'rg', + * textures apply gamma filter with 0.5 by default if not overridden + * todo: allow to use_[filter][X] to distinguish between textures + * + * @member {object} + */ +$.WebGLModule.ShaderLayer.defaultControls = {}; + + +/** + * todo make blending more 'nice' + * Available use_mode modes + * @type {{show: string, mask: string}} + */ +$.WebGLModule.ShaderLayer.modes = { + show: "show", + mask: "blend" +}; +$.WebGLModule.ShaderLayer.modes["mask_clip"] = "blend_clip"; //todo parser error not camel case +$.WebGLModule.ShaderLayer.__globalIncludes = {}; +$.WebGLModule.ShaderLayer.__chanPattern = new RegExp('[rgba]{1,4}'); + +/** + * Factory Manager for predefined UIControls + * - you can manage all your UI control logic within your shader implementation + * and not to touch this class at all, but here you will find some most common + * or some advanced controls ready to use, simple and powerful + * - registering an IComponent implementation (or an UiElement) in the factory results in its support + * among all the shaders (given the GLSL type, result of sample(...) matches). + * - UiElements are objects to create simple controls quickly and get rid of code duplicity, + * for more info @see OpenSeadragon.WebGLModule.UIControls.register() + * @class OpenSeadragon.WebGLModule.UIControls + */ +$.WebGLModule.UIControls = class { + + /** + * Get all available control types + * @return {string[]} array of available control types + */ + static types() { + return Object.keys(this._items).concat(Object.keys(this._impls)); + } + + /** + * Get an element used to create simple controls, if you want + * an implementation of the controls themselves (IControl), use build(...) to instantiate + * @param {string} id type of the control + * @return {*} + */ + static getUiElement(id) { + let ctrl = this._items[id]; + if (!ctrl) { + console.error("Invalid control: " + id); + ctrl = this._items["number"]; + } + return ctrl; + } + + /** + * Get an element used to create advanced controls, if you want + * an implementation of simple controls, use build(...) to instantiate + * @param {string} id type of the control + * @return {OpenSeadragon.WebGLModule.UIControls.IControl} + */ + static getUiClass(id) { + let ctrl = this._impls[id]; + if (!ctrl) { + console.error("Invalid control: " + id); + ctrl = this._impls["colormap"]; + } + return ctrl; + } + + /** + * Build UI control object based on given parameters + * @param {OpenSeadragon.WebGLModule.ShaderLayer} context owner of the control + * @param {string} name name used for the layer, should be unique among different context types + * @param {object|*} params parameters passed to the control (defined by the control) or set as default value if not object + * @param {object} defaultParams default parameters that the shader might leverage above defaults of the control itself + * @param {function} accepts required GLSL type of the control predicate, for compatibility typechecking + * @param {object} requiredParams parameters that override anything sent by user or present by defaultParams + * @return {OpenSeadragon.WebGLModule.UIControls.IControl} + */ + static build(context, name, params, defaultParams = {}, accepts = () => true, requiredParams = {}) { + //if not an object, but a value: make it the default one + if (!(typeof params === 'object')) { + params = {default: params}; + } + let originalType = defaultParams.type; + + defaultParams = $.extend(true, {}, defaultParams, params, requiredParams); + + if (!this._items[defaultParams.type]) { + if (!this._impls[defaultParams.type]) { + return this._buildFallback(defaultParams.type, originalType, context, + name, params, defaultParams, accepts, requiredParams); + } + + let cls = new this._impls[defaultParams.type]( + context, name, `${name}_${context.uid}`, defaultParams + ); + if (accepts(cls.type, cls)) { + return cls; + } + return this._buildFallback(defaultParams.type, originalType, context, + name, params, defaultParams, accepts, requiredParams); + } else { + let contextComponent = this.getUiElement(defaultParams.type); + let comp = new $.WebGLModule.UIControls.SimpleUIControl( + context, name, `${name}_${context.uid}`, defaultParams, contextComponent + ); + if (accepts(comp.type, comp)) { + return comp; + } + return this._buildFallback(contextComponent.glType, originalType, context, + name, params, defaultParams, accepts, requiredParams); + } + } + + /** + * Register simple UI element by providing necessary object + * implementation: + * { defaults: function() {...}, // object with all default values for all supported parameters + html: function(uniqueId, params, css="") {...}, //how the HTML UI controls look like + glUniformFunName: function() {...}, //what function webGL uses to pass this attribute to GPU + decode: function(fromValue) {...}, //parse value obtained from HTML controls into something + gl[glUniformFunName()](...) can pass to GPU + glType: //what's the type of this parameter wrt. GLSL: int? vec3? + * @param type the identifier under which is this control used: lookup made against params.type + * @param uiElement the object to register, fulfilling the above-described contract + */ + static register(type, uiElement) { + function check(el, prop, desc) { + if (!el[prop]) { + console.warn(`Skipping UI control '${type}' due to '${prop}': missing ${desc}.`); + return false; + } + return true; + } + + if (check(uiElement, "defaults", "defaults():object") && + check(uiElement, "html", "html(uniqueId, params, css):htmlString") && + check(uiElement, "glUniformFunName", "glUniformFunName():string") && + check(uiElement, "decode", "decode(encodedValue):") && + check(uiElement, "normalize", "normalize(value, params):") && + check(uiElement, "sample", "sample(value, valueGlType):glslString") && + check(uiElement, "glType", "glType:string") + ) { + uiElement.prototype.getName = () => type; + if (this._items[type]) { + console.warn("Registering an already existing control component: ", type); + } + uiElement["uiType"] = type; + this._items[type] = uiElement; + } + } + + /** + * Register class as a UI control + * @param {string} type unique control name / identifier + * @param {OpenSeadragon.WebGLModule.UIControls.IControl} cls to register, implementation class of the controls + */ + static registerClass(type, cls) { + //todo not really possible with syntax checker :/ + // if ($.WebGLModule.UIControls.IControl.isPrototypeOf(cls)) { + cls.prototype.getName = () => type; + + if (this._items[type]) { + console.warn("Registering an already existing control component: ", type); + } + cls._uiType = type; + this._impls[type] = cls; + // } else { + // console.warn(`Skipping UI control '${type}': does not inherit from $.WebGLModule.UIControls.IControl.`); + // } + } + + ///////////////////////// + /////// PRIVATE ///////// + ///////////////////////// + + + static _buildFallback(newType, originalType, context, name, params, defaultParams, requiredType, requiredParams) { + //repeated check when building object from type + + params.interactive = false; + if (originalType === newType) { //if default and new equal, fail - recursion will not help + console.error(`Invalid parameter in shader '${params.type}': the parameter could not be built.`); + return undefined; + } else { //otherwise try to build with originalType (default) + params.type = originalType; + console.warn("Incompatible UI control type '" + newType + "': making the input non-interactive."); + return this.build(context, name, params, defaultParams, requiredType, requiredParams); + } + } +}; + +//implementation of UI control classes +//more complex functionality +$.WebGLModule.UIControls._impls = { + //colormap: $.WebGLModule.UIControls.ColorMap +}; +//implementation of UI control objects +//simple functionality +$.WebGLModule.UIControls._items = { + number: { + defaults: function() { + return {title: "Number", interactive: true, default: 0, min: 0, max: 100, step: 1}; + }, + html: function(uniqueId, params, css = "") { + let title = params.title ? ` ${params.title}` : ""; + return `${title}`; + }, + glUniformFunName: function() { + return "uniform1f"; + }, + decode: function(fromValue) { + return Number.parseFloat(fromValue); + }, + normalize: function(value, params) { + return (value - params.min) / (params.max - params.min); + }, + sample: function(name, ratio) { + return name; + }, + glType: "float", + uiType: "number" + }, + + range: { + defaults: function() { + return {title: "Range", interactive: true, default: 0, min: 0, max: 100, step: 1}; + }, + html: function(uniqueId, params, css = "") { + let title = params.title ? ` ${params.title}` : ""; + return `${title}`; + }, + glUniformFunName: function() { + return "uniform1f"; + }, + decode: function(fromValue) { + return Number.parseFloat(fromValue); + }, + normalize: function(value, params) { + return (value - params.min) / (params.max - params.min); + }, + sample: function(name, ratio) { + return name; + }, + glType: "float", + uiType: "range" + }, + + color: { + defaults: function() { + return { title: "Color", interactive: true, default: "#fff900" }; + }, + html: function(uniqueId, params, css = "") { + let title = params.title ? ` ${params.title}` : ""; + return `${title}`; + }, + glUniformFunName: function() { + return "uniform3fv"; + }, + decode: function(fromValue) { + try { + let index = fromValue.startsWith("#") ? 1 : 0; + return [ + parseInt(fromValue.slice(index, index + 2), 16) / 255, + parseInt(fromValue.slice(index + 2, index + 4), 16) / 255, + parseInt(fromValue.slice(index + 4, index + 6), 16) / 255 + ]; + } catch (e) { + return [0, 0, 0]; + } + }, + normalize: function(value, params) { + return value; + }, + sample: function(name, ratio) { + return name; + }, + glType: "vec3", + uiType: "color" + }, + + bool: { + defaults: function() { + return { title: "Checkbox", interactive: true, default: true }; + }, + html: function(uniqueId, params, css = "") { + let title = params.title ? ` ${params.title}` : ""; + let value = this.decode(params.default) ? "checked" : ""; + //note a bit dirty, but works :) - we want uniform access to 'value' property of all inputs + return `${title}`; + }, + glUniformFunName: function() { + return "uniform1i"; + }, + decode: function(fromValue) { + return fromValue && fromValue !== "false" ? 1 : 0; + }, + normalize: function(value, params) { + return value; + }, + sample: function(name, ratio) { + return name; + }, + glType: "bool", + uiType: "bool" + } +}; + +/** + * @interface + */ +$.WebGLModule.UIControls.IControl = class { + + /** + * Sets common properties needed to create the controls: + * this.context @extends WebGLModule.ShaderLayer - owner context + * this.name - name of the parameter for this.context.[load/store]Property(...) call + * this.id - unique ID for HTML id attribute, to be able to locate controls in DOM, + * created as ${uniq}${name}-${context.uid} + * this.webGLVariableName - unique webgl uniform variable name, to not to cause conflicts + * + * If extended (class-based definition, see registerCass) children should define constructor as + * + * @example + * constructor(context, name, webGLVariableName, params) { + * super(context, name, webGLVariableName); + * ... + * //possibly make use of params: + * this.params = this.getParams(params); + * + * //now access params: + * this.params... + * } + * + * @param {WebGLModule.ShaderLayer} context shader context owning this control + * @param {string} name name of the control (key to the params in the shader configuration) + * @param {string} webGLVariableName configuration parameters, + * depending on the params.type field (the only one required) + * @param {string} uniq another element to construct the DOM id from, mostly for compound controls + */ + constructor(context, name, webGLVariableName, uniq = "") { + this.context = context; + this.id = `${uniq}${name}-${context.uid}`; + this.name = name; + this.webGLVariableName = webGLVariableName; + this._params = {}; + this.__onchange = {}; + } + + /** + * Safely sets outer params with extension from 'supports' + * - overrides 'supports' values with the correct type (derived from supports or supportsAll) + * - sets 'supports' as defaults if not set + * @param params + */ + getParams(params) { + const t = this.constructor.getVarType; + function mergeSafeType(mask, from, possibleTypes) { + const to = Object.assign({}, mask); + Object.keys(from).forEach(key => { + const tVal = to[key], + fVal = from[key], + tType = t(tVal), + fType = t(fVal); + + const typeList = possibleTypes ? possibleTypes[key] : undefined, + pTypeList = typeList ? typeList.map(x => t(x)) : []; + + //our type detector distinguishes arrays and objects + if (tVal && fVal && tType === "object" && fType === "object") { + to[key] = mergeSafeType(tVal, fVal, typeList); + } else if (tVal === undefined || tType === fType || pTypeList.includes(fType)) { + to[key] = fVal; + } else if (fType === "string") { + //try parsing NOTE: parsing from supportsAll is ignored! + if (tType === "number") { + const parsed = Number.parseFloat(fVal); + if (!Number.isNaN(parsed)) { + to[key] = parsed; + } + } else if (tType === "boolean") { + const value = fVal.toLowerCase(); + if (value === "false") { + to[key] = false; + } + if (value === "true") { + to[key] = true; + } + } + } + }); + return to; + } + return mergeSafeType(this.supports, params, this.supportsAll); + } + + /** + * Safely check certain param value + * @param value value to check + * @param defaultValue default value to return if check fails + * @param paramName name of the param to check value type against + * @return {boolean|number|*} + */ + getSafeParam(value, defaultValue, paramName) { + const t = this.constructor.getVarType; + function nest(suppNode, suppAllNode) { + if (t(suppNode) !== "object") { + return [suppNode, suppAllNode]; + } + if (!suppNode[paramName]) { + return [undefined, undefined]; + } + return nest(suppNode[paramName], suppAllNode ? suppAllNode[paramName] : undefined); + } + const param = nest(this.supports, this.supportsAll), + tParam = t(param[0]); + + if (tParam === "object") { + console.warn("Parameters should not be stored at object level. No type inspection is done."); + return true; //no supported inspection + } + const tValue = t(value); + //supported type OR supports all types includes the type + if (tValue === tParam || (param[1] && param[1].map(t).includes(tValue))) { + return value; + } + + if (tValue === "string") { + //try parsing NOTE: parsing from supportsAll is ignored! + if (tParam === "number") { + const parsed = Number.parseFloat(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } else if (tParam === "boolean") { + const val = value.toLowerCase(); + if (val === "false") { + return false; + } + if (val === "true") { + return true; + } + } + } + + //todo test + console.debug("Failed to load safe param -> new feature, debugging! ", value, defaultValue, paramName); + return defaultValue; + } + + /** + * Uniform behaviour wrt type checking in shaders + * @param x + * @return {string} + */ + static getVarType(x) { + if (x === undefined) { + return "undefined"; + } + if (x === null) { + return "null"; + } + return Array.isArray(x) ? "array" : typeof x; + } + + /** + * JavaScript initialization + * - read/store default properties here using this.context.[load/store]Property(...) + * - work with own HTML elements already attached to the DOM + * - set change listeners, input values! + */ + init() { + throw "WebGLModule.UIControls.IControl::init() must be implemented."; + } + + /** + * TODO: improve overall setter API + * Allows to set the control value programatically. + * Does not trigger canvas re-rednreing, must be done manually (e.g. control.context.invalidate()) + * @param encodedValue any value the given control can support, encoded + * (e.g. as the control acts on the GUI - for input number of + * values between 5 and 42, the value can be '6' or 6 or 6.15 + */ + set(encodedValue) { + throw "WebGLModule.UIControls.IControl::set() must be implemented."; + } + + /** + * Called when an image is rendered + * @param program WebglProgram instance + * @param {WebGLRenderingContextBase} gl + */ + glDrawing(program, gl) { + //the control should send something to GPU + throw "WebGLModule.UIControls.IControl::glDrawing() must be implemented."; + } + + /** + * Called when associated webgl program is switched to + * @param program WebglProgram instance + * @param gl WebGL Context + */ + glLoaded(program, gl) { + //the control should send something to GPU + throw "WebGLModule.UIControls.IControl::glLoaded() must be implemented."; + } + + /** + * Get the UI HTML controls + * - these can be referenced in this.init(...) + * - should respect this.params.interactive attribute and return non-interactive output if interactive=false + * - don't forget to no to work with DOM elements in init(...) in this case + */ + toHtml(breakLine = true, controlCss = "") { + throw "WebGLModule.UIControls.IControl::toHtml() must be implemented."; + } + + /** + * Handles how the variable is being defined in GLSL + * - should use variable names derived from this.webGLVariableName + */ + define() { + throw "WebGLModule.UIControls.IControl::define() must be implemented."; + } + + /** + * Sample the parameter using ratio as interpolation, must be one-liner expression so that GLSL code can write + * `vec3 mySampledValue = ${this.color.sample("0.2")};` + * NOTE: you can define your own global-scope functions to keep one-lined sampling, + * see this.context.includeGlobalCode(...) + * @param {(string|undefined)} value openGL value/variable, used in a way that depends on the UI control currently active + * (do not pass arguments, i.e. 'undefined' just get that value, note that some inputs might require you do it..) + * @param {string} valueGlType GLSL type of the value + * @return {string} valid GLSL oneliner (wihtout ';') for sampling the value, or invalid code (e.g. error message) to signal error + */ + sample(value = undefined, valueGlType = 'void') { + throw "WebGLModule.UIControls.IControl::sample() must be implemented."; + } + + /** + * Parameters supported by this UI component, must contain at least + * - 'interactive' - type bool, enables and disables the control interactivity + * (by changing the content available when rendering html) + * - 'title' - type string, the control title + * + * Additionally, for compatibility reasons, you should, if possible, define + * - 'default' - type any; the default value for the particular control + * @return {{}} name: default value mapping + */ + get supports() { + throw "WebGLModule.UIControls.IControl::supports must be implemented."; + } + + /** + * Type definitions for supports. Can return empty object. In case of missing + * type definitions, the type is derived from the 'supports()' default value type. + * + * Each key must be an array of default values for the given key if applicable. + * This is an _extension_ to the supports() and can be used only for keys that have more + * than one default type applicable + * @return {{}} + */ + get supportsAll() { + throw "WebGLModule.UIControls.IControl::typeDefs must be implemented."; + } + + /** + * GLSL type of this control: what type is returned from this.sample(...) ? + * @return {string} + */ + get type() { + throw "WebGLModule.UIControls.IControl::type must be implemented."; + } + + /** + * Raw value sent to the GPU, note that not necessarily typeof raw() === type() + * some controls might send whole arrays of data (raw) and do smart sampling such that type is only a number + * @return {any} + */ + get raw() { + throw "WebGLModule.UIControls.IControl::raw must be implemented."; + } + + /** + * Encoded value as used in the UI, e.g. a name of particular colormap, or array of string values of breaks... + * @return {any} + */ + get encoded() { + throw "WebGLModule.UIControls.IControl::encoded must be implemented."; + } + + ////////////////////////////////////// + //////// COMMON API ////////////////// + ////////////////////////////////////// + + /** + * The control type component was registered with. Handled internally. + * @return {*} + */ + get uiControlType() { + return this.constructor._uiType; + } + + /** + * Get current control parameters + * the control should set the value as this._params = this.getParams(incomingParams); + * @return {{}} + */ + get params() { + return this._params; + } + + /** + * Automatically overridden to return the name of the control it was registered with + * @return {string} + */ + getName() { + return "IControl"; + } + + /** + * Load a value from cache to support its caching - should be used on all values + * that are available for the user to play around with and change using UI controls + * + * @param defaultValue value to return in case of no cached value + * @param paramName name of the parameter, must be equal to the name from 'supports' definition + * - default value can be empty string + * @return {*} cached or default value + */ + load(defaultValue, paramName = "") { + if (paramName === "default") { + paramName = ""; + } + const value = this.context.loadProperty(this.name + paramName, defaultValue); + //check param in case of input cache collision between shader types + return this.getSafeParam(value, defaultValue, paramName === "" ? "default" : paramName); + } + + /** + * Store a value from cache to support its caching - should be used on all values + * that are available for the user to play around with and change using UI controls + * + * @param value to store + * @param paramName name of the parameter, must be equal to the name from 'supports' definition + * - default value can be empty string + */ + store(value, paramName = "") { + if (paramName === "default") { + paramName = ""; + } + return this.context.storeProperty(this.name + paramName, value); + } + + /** + * On parameter change register self + * @param {string} event which event to fire on + * - events are with inputs the names of supported parameters (this.supports), separated by dot if nested + * - most controls support "default" event - change of default value + * - see specific control implementation to see what events are fired (Advanced Slider fires "breaks" and "mask" for instance) + * @param {function} clbck(rawValue, encodedValue, context) call once change occurs, context is the control instance + */ + on(event, clbck) { + this.__onchange[event] = clbck; //only one possible event -> rewrite? + } + + /** + * Clear events of the event type + * @param {string} event type + */ + off(event) { + delete this.__onchange[event]; + } + + /** + * Clear ALL events + */ + clearEvents() { + this.__onchange = {}; + } + + /** + * Invoke changed value event + * -- should invoke every time a value changes !driven by USER!, and use unique or compatible + * event name (event 'value') so that shader knows what changed + * @param event event to call + * @param value decoded value of encodedValue + * @param encodedValue value that was received from the UI input + * @param context self reference to bind to the callback + */ + changed(event, value, encodedValue, context) { + if (typeof this.__onchange[event] === "function") { + this.__onchange[event](value, encodedValue, context); + } + } + + +}; + + +/** + * Generic UI control implementations + * used if: + * { + * type: "CONTROL TYPE", + * ... + * } + * + * The subclass constructor should get the context reference, the name + * of the input and the parametrization. + * + * Further parameters passed are dependent on the control type, see + * @ WebGLModule.UIControls + * + * @class WebGLModule.UIControls.SimpleUIControl + */ +$.WebGLModule.UIControls.SimpleUIControl = class extends $.WebGLModule.UIControls.IControl { + + //uses intristicComponent that holds all specifications needed to work with the component uniformly + constructor(context, name, webGLVariableName, params, intristicComponent, uniq = "") { + super(context, name, webGLVariableName, uniq); + this.component = intristicComponent; + this._params = this.getParams(params); + + this.encodedValue = this.load(this.params.default); + //this unfortunatelly makes cache erasing and rebuilding vis impossible, the shader part has to be fully re-instantiated + this.params.default = this.encodedValue; + } + + init() { + this.value = this.component.normalize(this.component.decode(this.encodedValue), this.params); + + if (this.params.interactive) { + const _this = this; + let node = document.getElementById(this.id); + if (node) { + let updater = function(e) { + _this.set(e.target.value); + _this.context.invalidate(); + }; + node.value = this.encodedValue; + node.addEventListener('change', updater); + } + } + } + + set(encodedValue) { + this.encodedValue = encodedValue; + this.value = this.component.normalize(this.component.decode(this.encodedValue), this.params); + this.changed("default", this.value, this.encodedValue, this); + this.store(this.encodedValue); + } + + glDrawing(program, gl) { + gl[this.component.glUniformFunName()](this.glLocation, this.value); + } + + glLoaded(program, gl) { + this.glLocation = gl.getUniformLocation(program, this.webGLVariableName); + } + + toHtml(breakLine = true, controlCss = "") { + if (!this.params.interactive) { + return ""; + } + const result = this.component.html(this.id, this.params, controlCss); + return breakLine ? `
${result}
` : result; + } + + define() { + return `uniform ${this.component.glType} ${this.webGLVariableName};`; + } + + sample(value = undefined, valueGlType = 'void') { + if (!value || valueGlType !== 'float') { + return this.webGLVariableName; + } + return this.component.sample(this.webGLVariableName, value); + } + + get uiControlType() { + return this.component["uiType"]; + } + + get supports() { + return this.component.defaults(); + } + + get supportsAll() { + return {}; + } + + get raw() { + return this.value; + } + + get encoded() { + return this.encodedValue; + } + + get type() { + return this.component.glType; + } +}; +})(OpenSeadragon); diff --git a/src/webgl/webGLContext.js b/src/webgl/webGLContext.js new file mode 100644 index 00000000..72e3e5ad --- /dev/null +++ b/src/webgl/webGLContext.js @@ -0,0 +1,391 @@ +(function($) { + +$.WebGLModule.determineContext = function( version ){ + const namespace = OpenSeadragon.WebGLModule; + for (let property in namespace) { + const context = namespace[ property ], + proto = context.prototype; + if( proto && + proto instanceof namespace.WebGLImplementation && + $.isFunction( proto.getVersion ) && + proto.getVersion.call( context ) === version + ){ + return context; + } + } + return null; +}; + +/** + * @interface OpenSeadragon.WebGLModule.webglContext + * Interface for the visualisation rendering implementation which can run + * on various GLSL versions + */ +$.WebGLModule.WebGLImplementation = class { + + /** + * Create a WebGL Renderer Context Implementation (version-dependent) + * @param {WebGLModule} renderer + * @param {WebGLRenderingContextBase} gl + * @param webglVersion + * @param {object} options + * @param {GLuint} options.wrap texture wrap parameteri + * @param {GLuint} options.magFilter texture filter parameteri + * @param {GLuint} options.minFilter texture filter parameteri + * @param {string|WebGLModule.IDataLoader} options.dataLoader class name or implementation of a given loader + */ + constructor(renderer, gl, webglVersion, options) { + //Set default blending to be MASK + this.renderer = renderer; + this.gl = gl; + this.glslBlendCode = "return background * (step(0.001, foreground.a));"; + + let Loader = options.dataLoader; + if (typeof Loader === "string") { + Loader = $.WebGLModule.Loaders[Loader]; + } + if (!Loader) { + throw("Unknown data loader: " + options.dataLoader); + } + if (!(Loader.prototype instanceof $.WebGLModule.IDataLoader)) { + throw("Incompatible texture loader used: " + options.dataLoader); + } + + this._texture = new Loader(gl, webglVersion, options); + if (!this.texture.supportsWebglVersion(this.getVersion())) { + throw("Incompatible texture loader version to the renderer context version! Context WebGL" + this.getVersion()); + } + } + + /** + * Static context creation (to avoid class instantiation in case of missing support) + * @param canvas + * @return {WebGLRenderingContextBase} //todo base is not common to all, remove from docs + */ + static create(canvas) { + throw("::create() must be implemented!"); + } + + /** + * @return {string} WebGL version used + */ + getVersion() { + return "undefined"; + } + + /** + * Get GLSL texture sampling code + * @return {string} GLSL code that is correct in texture sampling wrt. WebGL version used + */ + get texture() { + return this._texture; + } + + /** + * Create a visualisation from the given JSON params + * @param {string[]} order keys of visualisation.shader in which order to build the visualization + * the order: painter's algorithm: the last drawn is the most visible + * @param {object} visualisation + * @param {[number]} shaderDataIndexToGlobalDataIndex + * @param {boolean} withHtml whether html should be also created (false if no UI controls are desired) + * @return {object} compiled specification object ready to be used by the wrapper, with the following keys: + {string} object.vertexShader vertex shader code + {string} object.fragmentShader fragment shader code + {string} object.html html for the UI + {number} object.usableShaders how many layers are going to be visualised + {(array|string[])} object.dataUrls ID's of data in use (keys of visualisation.shaders object) in desired order + the data is guaranteed to arrive in this order (images stacked below each other in imageElement) + */ + compileSpecification(order, visualisation, shaderDataIndexToGlobalDataIndex, withHtml) { + throw("::compileSpecification() must be implemented!"); + } + + /** + * Called once program is switched to: initialize all necessary items + * @param {WebGLProgram} program used program + * @param {OpenSeadragon.WebGLModule.RenderingConfig} currentConfig JSON parameters used for this visualisation + */ + programLoaded(program, currentConfig) { + throw("::programLoaded() must be implemented!"); + } + + /** + * Draw on the canvas using given program + * @param {WebGLProgram} program used program + * @param {OpenSeadragon.WebGLModule.RenderingConfig} currentConfig JSON parameters used for this visualisation + * + * @param {string} id dataId + * @param {object} tileOpts + * @param {number} tileOpts.zoom value passed to the shaders as zoom_level + * @param {number} tileOpts.pixelSize value passed to the shaders as pixel_size_in_fragments + * @param {OpenSeadragon.Mat3} tileOpts.transform position of the rendered tile + */ + programUsed(program, currentConfig, id, tileOpts) { + throw("::programUsed() must be implemented!"); + } + + /** + * Code to be included only once, required by given shader type (keys are considered global) + * @param {string} type shader type + * @returns {object} global-scope code used by the shader in format + */ + globalCodeRequiredByShaderType(type) { + return $.WebGLModule.ShaderMediator.getClass(type).__globalIncludes; + } + + /** + * Blend equation sent from the outside, must be respected + * @param glslCode code for blending, using two variables: 'foreground', 'background' + * @example + * //The shader context must define the following: + * + * vec4 some_blending_name_etc(in vec4 background, in vec4 foreground) { + * // << glslCode >> + * } + * + * void blend_clip(vec4 input) { + * //for details on clipping mask approach see show() below + * // <> + * } + * + * void blend(vec4 input) { //must be called blend, API + * // <> + * } + * + * //Also, default alpha blending equation 'show' must be implemented: + * void show(vec4 color) { + * //pseudocode + * //note that the blending output should not immediatelly work with 'color' but perform caching of the color, + * //render the color given in previous call and at the execution end of main call show(vec4(.0)) + * //this way, the previous color is not yet blended for the next layer show/blend/blend_clip which can use it to create a clipping mask + * + * compute t = color.a + background.a - color.a*background.a; + * output vec4((color.rgb * color.a + background.rgb * background.a - background.rgb * (background.a * color.a)) / t, t) + * } + */ + setBlendEquation(glslCode) { + this.glslBlendCode = glslCode; + } +}; + +$.WebGLModule.WebGL20 = class extends $.WebGLModule.WebGLImplementation { + /** + * + * @param {OpenSeadragon.WebGLModule} renderer + * @param {WebGL2RenderingContext} gl + * @param options + */ + constructor(renderer, gl, options) { + super(renderer, gl, "2.0", options); + this.emptyBuffer = gl.createBuffer(); + } + + getVersion() { + return "2.0"; + } + + static create(canvas) { + return canvas.getContext('webgl2', { premultipliedAlpha: false, alpha: true }); + } + + //todo try to implement on the global scope version-independntly + compileSpecification(order, visualisation, shaderDataIndexToGlobalDataIndex, withHtml) { + var definition = "", + execution = "", + html = "", + _this = this, + usableShaders = 0, + globalScopeCode = {}; + + order.forEach(dataId => { + let layer = visualisation.shaders[dataId]; + layer.rendering = false; + + if (layer.type === "none") { + //prevents the layer from being accounted for + layer.error = "Not an error - layer type none."; + } else if (layer.error) { + if (withHtml) { + html = _this.renderer.htmlShaderPartHeader(layer.name, layer.error, dataId, false, layer, false) + html; + } + console.warn(layer.error, layer["desc"]); + + } else if (layer._renderContext && (layer._index || layer._index === 0)) { + let visible = false; + usableShaders++; + + //make visible textures if 'visible' flag set + if (layer.visible) { + let renderCtx = layer._renderContext; + definition += renderCtx.getFragmentShaderDefinition() + ` +vec4 lid_${layer._index}_xo() { + ${renderCtx.getFragmentShaderExecution()} +}`; + if (renderCtx.opacity) { + execution += ` + vec4 l${layer._index}_out = lid_${layer._index}_xo(); + l${layer._index}_out.a *= ${renderCtx.opacity.sample()}; + ${renderCtx.__mode}(l${layer._index}_out);`; + } else { + execution += ` + ${renderCtx.__mode}(lid_${layer._index}_xo());`; + } + + layer.rendering = true; + visible = true; + OpenSeadragon.extend(globalScopeCode, _this.globalCodeRequiredByShaderType(layer.type)); + } + + //reverse order append to show first the last drawn element (top) + if (withHtml) { + html = _this.renderer.htmlShaderPartHeader(layer.name, + layer._renderContext.htmlControls(), dataId, visible, layer, true) + html; + } + } else { + if (withHtml) { + html = _this.renderer.htmlShaderPartHeader(layer.name, + `The requested visualisation type does not work properly.`, dataId, false, layer, false) + html; + } + console.warn("Invalid shader part.", "Missing one of the required elements.", layer); + } + }); + + return { + vertexShader: this.getVertexShader(), + fragmentShader: this.getFragmentShader(definition, execution, shaderDataIndexToGlobalDataIndex, globalScopeCode), + html: html, + usableShaders: usableShaders, + dataUrls: this.renderer._dataSources + }; + } + + getFragmentShader(definition, execution, shaderDataIndexToGlobalDataIndex, globalScopeCode) { + return `#version 300 es +precision mediump float; +precision mediump sampler2DArray; + +${this.texture.declare(shaderDataIndexToGlobalDataIndex)} +uniform float pixel_size_in_fragments; +uniform float zoom_level; +uniform vec2 u_tile_size; +vec4 _last_rendered_color = vec4(.0); + +in vec2 tile_texture_coords; + +out vec4 final_color; + +bool close(float value, float target) { + return abs(target - value) < 0.001; +} + +void show(vec4 color) { + //premultiplied alpha blending + vec4 fg = _last_rendered_color; + _last_rendered_color = color; + vec4 pre_fg = vec4(fg.rgb * fg.a, fg.a); + final_color = pre_fg + final_color * (1.0-fg.a); +} + +void finalize() { + show(vec4(.0)); + + if (close(final_color.a, 0.0)) { + final_color = vec4(0.); + } else { + final_color = vec4(final_color.rgb/final_color.a, final_color.a); + } +} + +vec4 blend_equation(in vec4 foreground, in vec4 background) { +${this.glslBlendCode} +} + +void blend_clip(vec4 foreground) { + _last_rendered_color = blend_equation(foreground, _last_rendered_color); +} + +void blend(vec4 foreground) { + show(_last_rendered_color); + final_color = blend_equation(foreground, final_color); + _last_rendered_color = vec4(.0); +} + +${Object.values(globalScopeCode).join("\n")} + +${definition} + +void main() { + ${execution} + + finalize(); +}`; + } + + getVertexShader() { + //UNPACK_FLIP_Y_WEBGL not supported with 3D textures so sample bottom up + return `#version 300 es +precision mediump float; + +uniform mat3 transform_matrix; +out vec2 tile_texture_coords; +const vec3 quad[4] = vec3[4] ( + vec3(0.0, 1.0, 1.0), + vec3(0.0, 0.0, 1.0), + vec3(1.0, 1.0, 1.0), + vec3(1.0, 0.0, 1.0) +); + +void main() { + vec3 vertex = quad[gl_VertexID]; + tile_texture_coords = vec2(vertex.x, -vertex.y); + gl_Position = vec4(transform_matrix * vertex, 1); +} +`; + } + + programLoaded(program, currentConfig) { + if (!this.renderer.running) { + return; + } + + let context = this.renderer, + gl = this.gl; + + // Allow for custom loading + gl.useProgram(program); + context.visualisationInUse(currentConfig); + context.glLoaded(gl, program, currentConfig); + + //Note that the drawing strategy is not to resize canvas, and simply draw everyhing on squares + this.texture.programLoaded(context, gl, program, currentConfig); + + //Empty ARRAY: get the vertices directly from the shader + gl.bindBuffer(gl.ARRAY_BUFFER, this.emptyBuffer); + } + + programUsed(program, currentConfig, id, tileOpts) { + if (!this.renderer.running) { + return; + } + // Allow for custom drawing in webGL and possibly avoid using webGL at all + + let context = this.renderer, + gl = this.gl; + + context.glDrawing(gl, program, currentConfig, tileOpts); + + // Set Attributes for GLSL + gl.uniform1f(gl.getUniformLocation(program, "pixel_size_in_fragments"), tileOpts.pixelSize || 1); + gl.uniform1f(gl.getUniformLocation(program, "zoom_level"), tileOpts.zoom || 1); + gl.uniformMatrix3fv(gl.getUniformLocation(program, "transform_matrix"), false, + tileOpts.transform || OpenSeadragon.Mat3.makeIdentity()); + + // Upload textures + this.texture.programUsed(context, currentConfig, id, program, gl); + + // Draw triangle strip (two triangles) from a static array defined in the vertex shader + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + } +}; + +})(OpenSeadragon);