diff --git a/config/jsdoc/api/index.md b/config/jsdoc/api/index.md index a8087bbda3..22e7043176 100644 --- a/config/jsdoc/api/index.md +++ b/config/jsdoc/api/index.md @@ -27,7 +27,8 @@ ol/layer/Tile
ol/layer/Image
ol/layer/Vector
- ol/layer/VectorTile + ol/layer/VectorTile
+ ol/layer/WebGLTile @@ -58,7 +59,7 @@

Sources and formats

- Tile sources for ol/layer/Tile + Tile sources for ol/layer/Tile or ol/layer/WebGLTile
Image sources for ol/layer/Image
Vector sources for ol/layer/Vector
Vector tile sources for ol/layer/VectorTile @@ -71,7 +72,7 @@

Projections

-

All coordinates and extents need to be provided in view projection (default: EPSG:3857). To transform, use ol/proj#transform() and ol/proj#transformExtent().

+

All coordinates and extents need to be provided in view projection (default: EPSG:3857). To transform coordinates from and to geographic, use ol/proj#fromLonLat() and ol/proj#toLonLat(). For extents and other projections, use ol/proj#transformExtent() and ol/proj#transform().

ol/proj

diff --git a/src/ol/layer/WebGLTile.js b/src/ol/layer/WebGLTile.js index bc90be434c..7080c7ce3c 100644 --- a/src/ol/layer/WebGLTile.js +++ b/src/ol/layer/WebGLTile.js @@ -74,7 +74,7 @@ import {assign} from '../obj.js'; /** * @param {Style} style The layer style. - * @param {number} bandCount The number of bands. + * @param {number} [bandCount] The number of bands. * @return {ParsedStyle} Shaders and uniforms generated from the style. */ function parseStyle(style, bandCount) { @@ -291,6 +291,7 @@ class WebGLTileLayer extends BaseTileLayer { vertexShader: parsedStyle.vertexShader, fragmentShader: parsedStyle.fragmentShader, uniforms: parsedStyle.uniforms, + className: this.getClassName(), }); } diff --git a/src/ol/source/DataTile.js b/src/ol/source/DataTile.js index 15c857aa6b..3a813aa34e 100644 --- a/src/ol/source/DataTile.js +++ b/src/ol/source/DataTile.js @@ -100,7 +100,7 @@ class DataTileSource extends TileSource { * @param {number} y Tile coordinate y. * @param {number} pixelRatio Pixel ratio. * @param {import("../proj/Projection.js").default} projection Projection. - * @return {!import("../Tile.js").default} Tile. + * @return {!DataTile} Tile. */ getTile(z, x, y, pixelRatio, projection) { const tileCoordKey = getKeyZXY(z, x, y); diff --git a/test/browser/karma.config.cjs b/test/browser/karma.config.cjs index 296d201efe..9cba92f393 100644 --- a/test/browser/karma.config.cjs +++ b/test/browser/karma.config.cjs @@ -71,6 +71,13 @@ module.exports = function (karma) { webpack: { devtool: 'inline-source-map', mode: 'development', + resolve: { + fallback: { + fs: false, + http: false, + https: false, + }, + }, module: { rules: [ { diff --git a/test/browser/spec/ol/datatile.test.js b/test/browser/spec/ol/datatile.test.js new file mode 100644 index 0000000000..6ec7c2168c --- /dev/null +++ b/test/browser/spec/ol/datatile.test.js @@ -0,0 +1,51 @@ +import DataTile from '../../../../src/ol/DataTile.js'; +import TileState from '../../../../src/ol/TileState.js'; + +describe('ol.DataTile', function () { + /** @type {Promise { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext('2d'); + context.fillStyle = 'red'; + context.fillRect(0, 0, 256, 256); + resolve(context.getImageData(0, 0, 256, 256).data); + }); + }; + }); + + describe('constructor', function () { + it('sets options', function () { + const tileCoord = [0, 0, 0]; + const tile = new DataTile({ + tileCoord: tileCoord, + loader: loader, + transition: 200, + }); + expect(tile.tileCoord).to.equal(tileCoord); + expect(tile.transition_).to.be(200); + expect(tile.loader_).to.equal(loader); + }); + }); + + describe('#load()', function () { + it('handles loading states correctly', function (done) { + const tileCoord = [0, 0, 0]; + const tile = new DataTile({ + tileCoord: tileCoord, + loader: loader, + }); + expect(tile.getState()).to.be(TileState.IDLE); + tile.load(); + expect(tile.getState()).to.be(TileState.LOADING); + setTimeout(() => { + expect(tile.getState()).to.be(TileState.LOADED); + done(); + }, 16); + }); + }); +}); diff --git a/test/browser/spec/ol/layer/webgltile.test.js b/test/browser/spec/ol/layer/webgltile.test.js new file mode 100644 index 0000000000..1fae9b1771 --- /dev/null +++ b/test/browser/spec/ol/layer/webgltile.test.js @@ -0,0 +1,139 @@ +import DataTileSource from '../../../../../src/ol/source/DataTile.js'; +import Map from '../../../../../src/ol/Map.js'; +import View from '../../../../../src/ol/View.js'; +import WebGLHelper from '../../../../../src/ol/webgl/Helper.js'; +import WebGLTileLayer from '../../../../../src/ol/layer/WebGLTile.js'; +import {createCanvasContext2D} from '../../../../../src/ol/dom.js'; + +describe('ol.layer.Tile', function () { + /** @type {WebGLTileLayer} */ + let layer; + /** @type {Map} */ + let map, target; + + beforeEach(function (done) { + layer = new WebGLTileLayer({ + className: 'testlayer', + source: new DataTileSource({ + loader(z, x, y) { + return new Promise((resolve) => { + resolve(new ImageData(256, 256)); + }); + }, + }), + style: { + variables: { + r: 0, + g: 255, + b: 0, + }, + color: ['color', ['var', 'r'], ['var', 'g'], ['var', 'b']], + }, + }); + target = document.createElement('div'); + target.style.width = '100px'; + target.style.height = '100px'; + document.body.appendChild(target); + map = new Map({ + target: target, + layers: [layer], + view: new View({ + center: [0, 0], + zoom: 2, + }), + }); + map.once('rendercomplete', () => done()); + }); + + afterEach(function () { + map.setTarget(null); + document.body.removeChild(target); + }); + + it('creates fragment and vertex shaders', function () { + const compileShaderSpy = sinon.spy(WebGLHelper.prototype, 'compileShader'); + layer.createRenderer(); + compileShaderSpy.restore(); + expect(compileShaderSpy.callCount).to.be(2); + expect(compileShaderSpy.getCall(0).args[0].replace(/[ \n]+/g, ' ')).to.be( + ` + #ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + #else + precision mediump float; + #endif + varying vec2 v_textureCoord; + uniform float u_transitionAlpha; + uniform float u_texturePixelWidth; + uniform float u_texturePixelHeight; + uniform float u_resolution; + uniform float u_zoom; + uniform float u_var_r; + uniform float u_var_g; + uniform float u_var_b; + uniform sampler2D u_tileTexture0; + void main() { + vec4 color0 = texture2D(u_tileTexture0, v_textureCoord); + vec4 color = color0; + color = vec4(u_var_r / 255.0, u_var_g / 255.0, u_var_b / 255.0, 1.0); + if (color.a == 0.0) { + discard; + } + gl_FragColor = color; + gl_FragColor.rgb *= gl_FragColor.a; + gl_FragColor *= u_transitionAlpha; + }`.replace(/[ \n]+/g, ' ') + ); + + expect(compileShaderSpy.getCall(1).args[0].replace(/[ \n]+/g, ' ')).to.be( + ` + attribute vec2 a_textureCoord; + uniform mat4 u_tileTransform; + uniform float u_depth; + + varying vec2 v_textureCoord; + void main() { + v_textureCoord = a_textureCoord; + gl_Position = u_tileTransform * vec4(a_textureCoord, u_depth, 1.0); + } + `.replace(/[ \n]+/g, ' ') + ); + }); + + it('updates style variables', function (done) { + layer.updateStyleVariables({ + r: 255, + g: 0, + b: 255, + }); + expect(layer.styleVariables_['r']).to.be(255); + const targetContext = createCanvasContext2D(100, 100); + layer.on('postrender', () => { + targetContext.clearRect(0, 0, 100, 100); + targetContext.drawImage(target.querySelector('.testlayer'), 0, 0); + }); + map.once('rendercomplete', () => { + expect(Array.from(targetContext.getImageData(0, 0, 1, 1).data)).to.eql([ + 255, 0, 255, 255, + ]); + done(); + }); + }); + + it('throws on incorrect style configs', function () { + function incorrectStyle() { + layer.style_ = { + variables: { + 'red': 25, + 'green': 200, + }, + exposure: 0, + contrast: 0, + saturation: 0, + color: ['color', ['var', 'red'], ['var', 'green'], ['var', 'blue']], + }; + layer.createRenderer(); + } + expect(incorrectStyle).to.throwException(); // missing 'blue' in styleVariables + }); +}); diff --git a/test/browser/spec/ol/renderer/webgl/tilelayer.test.js b/test/browser/spec/ol/renderer/webgl/tilelayer.test.js new file mode 100644 index 0000000000..d1dc885246 --- /dev/null +++ b/test/browser/spec/ol/renderer/webgl/tilelayer.test.js @@ -0,0 +1,95 @@ +import TileQueue from '../../../../../../src/ol/TileQueue.js'; +import TileState from '../../../../../../src/ol/TileState.js'; +import WebGLTileLayer from '../../../../../../src/ol/layer/WebGLTile.js'; +import {DataTile} from '../../../../../../src/ol/source.js'; +import {VOID} from '../../../../../../src/ol/functions.js'; +import {create} from '../../../../../../src/ol/transform.js'; +import {createCanvasContext2D} from '../../../../../../src/ol/dom.js'; +import {get} from '../../../../../../src/ol/proj.js'; + +describe('ol.renderer.webgl.TileLayer', function () { + /** @type {import("../../../../../../src/ol/renderer/webgl/TileLayer.js").default} */ + let renderer; + /** @type {WebGLTileLayer} */ + let tileLayer; + /** @type {import('../../../../../../src/ol/PluggableMap.js').FrameState} */ + let frameState; + beforeEach(function () { + const size = 256; + const context = createCanvasContext2D(size, size); + + tileLayer = new WebGLTileLayer({ + source: new DataTile({ + loader: function (z, x, y) { + context.clearRect(0, 0, size, size); + context.fillStyle = 'rgba(100, 100, 100, 0.5)'; + context.fillRect(0, 0, size, size); + const data = context.getImageData(0, 0, size, size).data; + return Promise.resolve(data); + }, + }), + }); + + renderer = tileLayer.createRenderer(); + + const proj = get('EPSG:3857'); + frameState = { + layerStatesArray: [tileLayer.getLayerState()], + layerIndex: 0, + extent: proj.getExtent(), + pixelRatio: 1, + pixelToCoordinateTransform: create(), + postRenderFunctions: [], + time: Date.now(), + viewHints: [], + viewState: { + center: [0, 0], + resolution: 156543.03392804097, + projection: proj, + }, + size: [256, 256], + usedTiles: {}, + wantedTiles: {}, + tileQueue: new TileQueue(VOID, VOID), + }; + }); + + it('#prepareFrame()', function () { + const source = tileLayer.getSource(); + tileLayer.setSource(null); + expect(renderer.prepareFrame(frameState)).to.be(false); + tileLayer.setSource(source); + expect(renderer.prepareFrame(frameState)).to.be(true); + const tileGrid = source.getTileGrid(); + tileLayer.setExtent(tileGrid.getTileCoordExtent([2, 0, 0])); + frameState.resolution = tileGrid.getResolution(2); + frameState.extent = tileGrid.getTileCoordExtent([2, 2, 2]); + frameState.layerStatesArray = [tileLayer.getLayerState()]; + expect(renderer.prepareFrame(frameState)).to.be(false); + }); + + it('#renderFrame()', function () { + const rendered = renderer.renderFrame(frameState); + expect(rendered).to.be.a(HTMLCanvasElement); + expect(frameState.tileQueue.getCount()).to.be(1); + expect(Object.keys(frameState.wantedTiles).length).to.be(1); + expect(frameState.postRenderFunctions.length).to.be(0); // no tile expired + expect(renderer.tileTextureCache_.count_).to.be(1); + }); + + it('#isDrawableTile()', function (done) { + const tile = tileLayer.getSource().getTile(0, 0, 0); + expect(renderer.isDrawableTile(tile)).to.be(false); + tileLayer.getSource().on('tileloadend', () => { + expect(renderer.isDrawableTile(tile)).to.be(true); + done(); + }); + tile.load(); + const errorTile = tileLayer.getSource().getTile(1, 0, 1); + errorTile.setState(TileState.ERROR); + tileLayer.setUseInterimTilesOnError(false); + expect(renderer.isDrawableTile(errorTile)).to.be(true); + tileLayer.setUseInterimTilesOnError(true); + expect(renderer.isDrawableTile(errorTile)).to.be(false); + }); +}); diff --git a/test/browser/spec/ol/source/datatile.test.js b/test/browser/spec/ol/source/datatile.test.js new file mode 100644 index 0000000000..0b3aafd3ef --- /dev/null +++ b/test/browser/spec/ol/source/datatile.test.js @@ -0,0 +1,42 @@ +import DataTile from '../../../../../src/ol/DataTile.js'; +import DataTileSource from '../../../../../src/ol/source/DataTile.js'; +import TileState from '../../../../../src/ol/TileState.js'; + +describe('ol.source.DataTile', function () { + /** @type {DataTileSource} */ + let source; + beforeEach(function () { + const loader = function (z, x, y) { + return new Promise((resolve) => { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 256; + const context = canvas.getContext('2d'); + // encode tile coordinate in rgb + context.fillStyle = `rgb(${z}, ${x % 255}, ${y % 255})`; + context.fillRect(0, 0, 256, 256); + resolve(context.getImageData(0, 0, 256, 256).data); + }); + }; + source = new DataTileSource({ + loader: loader, + }); + }); + + describe('#getTile()', function () { + it('gets tiles and fires a tileloadend event', function (done) { + const tile = source.getTile(3, 2, 1); + expect(tile).to.be.a(DataTile); + expect(tile.state).to.be(TileState.IDLE); + + source.on('tileloadend', () => { + expect(tile.state).to.be(TileState.LOADED); + // decode tile coordinate from rgb + expect(Array.from(tile.getData().slice(0, 3))).to.eql([3, 2, 1]); + done(); + }); + + tile.load(); + }); + }); +}); diff --git a/test/browser/spec/ol/source/geotiff.test.js b/test/browser/spec/ol/source/geotiff.test.js new file mode 100644 index 0000000000..4efb2199a1 --- /dev/null +++ b/test/browser/spec/ol/source/geotiff.test.js @@ -0,0 +1,45 @@ +import GeoTIFFSource from '../../../../../src/ol/source/GeoTIFF.js'; +import State from '../../../../../src/ol/source/State.js'; +import TileState from '../../../../../src/ol/TileState.js'; + +describe('ol.source.GeoTIFF', function () { + /** @type {GeoTIFFSource} */ + let source; + beforeEach(function () { + source = new GeoTIFFSource({ + sources: [ + { + url: 'spec/ol/source/images/0-0-0.tif', + }, + ], + }); + }); + + it('manages load states', function (done) { + expect(source.getState()).to.be(State.LOADING); + source.on('change', () => { + expect(source.getState()).to.be(State.READY); + done(); + }); + }); + + it('configures itself from source metadata', function (done) { + source.on('change', () => { + expect(source.bandCount).to.be(3); + expect(source.getTileGrid().getResolutions().length).to.be(1); + expect(source.projection.getCode()).to.be('EPSG:4326'); + done(); + }); + }); + + it('loads tiles', function (done) { + source.on('change', () => { + const tile = source.getTile(0, 0, 0); + source.on('tileloadend', () => { + expect(tile.getState()).to.be(TileState.LOADED); + done(); + }); + tile.load(); + }); + }); +}); diff --git a/test/browser/spec/ol/source/images/0-0-0.tif b/test/browser/spec/ol/source/images/0-0-0.tif new file mode 100644 index 0000000000..3507f24029 Binary files /dev/null and b/test/browser/spec/ol/source/images/0-0-0.tif differ diff --git a/test/browser/spec/ol/style/expressions.test.js b/test/browser/spec/ol/style/expressions.test.js index fcf1f9e004..d2e6980b0c 100644 --- a/test/browser/spec/ol/style/expressions.test.js +++ b/test/browser/spec/ol/style/expressions.test.js @@ -241,6 +241,12 @@ describe('ol.style.expressions', function () { ['-', ['get', 'attr3'], ['get', 'attr2']], ]) ).to.eql('abs((a_attr3 - a_attr2))'); + expect(expressionToGlsl(context, ['sin', 1])).to.eql('sin(1.0)'); + expect(expressionToGlsl(context, ['cos', 1])).to.eql('cos(1.0)'); + expect(expressionToGlsl(context, ['atan', 1])).to.eql('atan(1.0)'); + expect(expressionToGlsl(context, ['atan', 1, 0.5])).to.eql( + 'atan(1.0, 0.5)' + ); expect(expressionToGlsl(context, ['>', 10, ['get', 'attr4']])).to.eql( '(10.0 > a_attr4)' ); @@ -283,6 +289,10 @@ describe('ol.style.expressions', function () { expect( expressionToGlsl(context, ['color', ['get', 'attr4'], 1, 2, 0.5]) ).to.eql('vec4(a_attr4 / 255.0, 1.0 / 255.0, 2.0 / 255.0, 0.5)'); + expect(expressionToGlsl(context, ['band', 1])).to.eql('color0[0]'); + expect(expressionToGlsl(context, ['band', 1, -1, 2])).to.eql( + 'texture2D(u_tileTexture0, v_textureCoord + vec2(-1.0 / u_texturePixelWidth, 2.0 / u_texturePixelHeight))[0]' + ); }); it('throws if the value does not match the type', function () { diff --git a/test/browser/spec/ol/webgl/tiletexture.test.js b/test/browser/spec/ol/webgl/tiletexture.test.js new file mode 100644 index 0000000000..ef6bc29733 --- /dev/null +++ b/test/browser/spec/ol/webgl/tiletexture.test.js @@ -0,0 +1,86 @@ +import DataTile from '../../../../../src/ol/DataTile.js'; +import DataTileSource from '../../../../../src/ol/source/DataTile.js'; +import ImageTile from '../../../../../src/ol/ImageTile.js'; +import TileState from '../../../../../src/ol/TileState.js'; +import TileTexture from '../../../../../src/ol/webgl/TileTexture.js'; +import WebGLArrayBuffer from '../../../../../src/ol/webgl/Buffer.js'; +import WebGLTileLayer from '../../../../../src/ol/layer/WebGLTile.js'; +import {createCanvasContext2D} from '../../../../../src/ol/dom.js'; + +describe('ol.webgl.TileTexture', function () { + /** @type {TileTexture} */ + let tileTexture; + + beforeEach(function () { + const layer = new WebGLTileLayer({ + source: new DataTileSource({ + loader(z, x, y) { + return new Promise((resolve) => { + const context = createCanvasContext2D(256, 256); + context.fillStyle = `rgb(${z}, ${x % 255}, ${y % 255})`; + context.fillRect(0, 0, 256, 256); + resolve(context.getImageData(0, 0, 256, 256).data); + }); + }, + }), + }); + const renderer = + /** @type {import("../../../../../src/ol/renderer/webgl/TileLayer.js").default} */ ( + layer.createRenderer() + ); + tileTexture = new TileTexture( + layer.getSource().getTile(3, 2, 1), + layer.getSource().getTileGrid(), + renderer.helper + ); + }); + + it('constructor', function () { + expect(tileTexture.tile.tileCoord).to.eql([3, 2, 1]); + expect(tileTexture.coords).to.be.a(WebGLArrayBuffer); + }); + + it('handles data tiles', function (done) { + const dataTile = tileTexture.tile; + expect(tileTexture.loaded).to.be(false); + expect(dataTile.getState()).to.be(TileState.IDLE); + tileTexture.addEventListener('change', () => { + if (dataTile.getState() === TileState.LOADED) { + expect(tileTexture.loaded).to.be(true); + done(); + } + }); + dataTile.load(); + }); + + it('handles image tiles', function () { + const imageTile = new ImageTile([0, 0, 0], TileState.LOADED); + tileTexture.setTile(imageTile); + expect(tileTexture.loaded).to.be(true); + }); + + it('registers and unregisters change listener', function () { + const tile = tileTexture.tile; + expect(tile.getListeners('change').length).to.be(2); + tileTexture.dispose(); + expect(tile.getListeners('change').length).to.be(1); + }); + + it('updates metadata and unregisters change listener when setting a different tile', function (done) { + const tile = tileTexture.tile; + expect(tile.getListeners('change').length).to.be(2); + const differentTile = new DataTile({ + tileCoord: [1, 0, 1], + loader(z, x, y) { + return Promise.resolve(new Uint8Array(256 * 256 * 3)); + }, + }); + tileTexture.setTile(differentTile); + expect(tile.getListeners('change').length).to.be(1); + tileTexture.addEventListener('change', () => { + expect(tileTexture.bandCount).to.be(3); + done(); + }); + differentTile.load(); + }); +}); diff --git a/test/rendering/cases/layer-tile-webgl/expected.png b/test/rendering/cases/layer-tile-webgl/expected.png new file mode 100644 index 0000000000..5d7011ff7d Binary files /dev/null and b/test/rendering/cases/layer-tile-webgl/expected.png differ diff --git a/test/rendering/cases/layer-tile-webgl/main.js b/test/rendering/cases/layer-tile-webgl/main.js new file mode 100644 index 0000000000..6eb87d2786 --- /dev/null +++ b/test/rendering/cases/layer-tile-webgl/main.js @@ -0,0 +1,26 @@ +import Map from '../../../../src/ol/Map.js'; +import TileLayer from '../../../../src/ol/layer/WebGLTile.js'; +import View from '../../../../src/ol/View.js'; +import XYZ from '../../../../src/ol/source/XYZ.js'; +import {fromLonLat} from '../../../../src/ol/proj.js'; + +const center = fromLonLat([8.6, 50.1]); + +new Map({ + layers: [ + new TileLayer({ + source: new XYZ({ + url: '/data/tiles/satellite/{z}/{x}/{y}.jpg', + transition: 0, + crossOrigin: 'anonymous', + }), + }), + ], + target: 'map', + view: new View({ + center: center, + zoom: 3, + }), +}); + +render();