From a4c421e6994ea253198d875bc0481baade27f142 Mon Sep 17 00:00:00 2001 From: GaborFarkas Date: Fri, 16 Jun 2017 12:40:45 +0200 Subject: [PATCH] Use glyph atlases --- src/ol/render/webgl/textreplay.js | 321 ++++++++++++++----- src/ol/typedefs.js | 8 + test/spec/ol/render/webgl/textreplay.test.js | 202 +++++++++--- test_rendering/spec/ol/style/text.test.js | 4 +- 4 files changed, 400 insertions(+), 135 deletions(-) diff --git a/src/ol/render/webgl/textreplay.js b/src/ol/render/webgl/textreplay.js index 43f65080e8..48f164c36b 100644 --- a/src/ol/render/webgl/textreplay.js +++ b/src/ol/render/webgl/textreplay.js @@ -6,6 +6,7 @@ goog.require('ol.dom'); goog.require('ol.has'); goog.require('ol.render.webgl'); goog.require('ol.render.webgl.TextureReplay'); +goog.require('ol.style.AtlasManager'); goog.require('ol.webgl.Buffer'); @@ -49,12 +50,7 @@ if (ol.ENABLE_WEBGL) { * lineWidth: number, * miterLimit: (number|undefined), * fillColor: (ol.ColorLike|null), - * text: (string|undefined), * font: (string|undefined), - * textAlign: (number|undefined), - * textBaseline: (number|undefined), - * offsetX: (number|undefined), - * offsetY: (number|undefined), * scale: (number|undefined)}} */ this.state_ = { @@ -66,18 +62,51 @@ if (ol.ENABLE_WEBGL) { lineWidth: 0, miterLimit: undefined, fillColor: null, - text: '', font: undefined, - textAlign: undefined, - textBaseline: undefined, - offsetX: undefined, - offsetY: undefined, scale: undefined }; - this.originX = 0; + /** + * @private + * @type {string} + */ + this.text_ = ''; - this.originY = 0; + /** + * @private + * @type {number|undefined} + */ + this.textAlign_ = undefined; + + /** + * @private + * @type {number|undefined} + */ + this.textBaseline_ = undefined; + + /** + * @private + * @type {number|undefined} + */ + this.offsetX_ = undefined; + + /** + * @private + * @type {number|undefined} + */ + this.offsetY_ = undefined; + + /** + * @private + * @type {Object.} + */ + this.atlases_ = {}; + + /** + * @private + * @type {ol.WebglGlyphAtlas|undefined} + */ + this.currAtlas_ = undefined; this.scale = 1; @@ -92,85 +121,152 @@ if (ol.ENABLE_WEBGL) { */ ol.render.webgl.TextReplay.prototype.drawText = function(flatCoordinates, offset, end, stride, geometry, feature) { - //For now we create one texture per feature. That is, only multiparts are grouped. - //TODO: speed up rendering with SDF, or at least glyph atlases - var state = this.state_; - if (state.text) { + if (this.text_) { this.startIndices.push(this.indices.length); this.startIndicesFeature.push(feature); - this.drawCoordinates( - flatCoordinates, offset, end, stride); + var glyphAtlas = this.currAtlas_; + var lines = this.text_.split('\n'); + var textSize = this.getTextSize_(lines); + var i, ii, j, jj, currX, currY, charArr, charInfo; + var anchorX = Math.round(textSize[0] * this.textAlign_ + this.offsetX_); + var anchorY = Math.round(textSize[1] * this.textBaseline_ + this.offsetY_); + var lineWidth = (this.state_.lineWidth / 2) * this.state_.scale; + + for (i = 0, ii = lines.length; i < ii; ++i) { + currX = 0; + currY = glyphAtlas.height * i; + charArr = lines[i].split(''); + + for (j = 0, jj = charArr.length; j < jj; ++j) { + charInfo = glyphAtlas.atlas.getInfo(charArr[j]); + + if (charInfo) { + var image = charInfo.image; + + this.anchorX = anchorX - currX; + this.anchorY = anchorY - currY; + this.originX = j === 0 ? charInfo.offsetX - lineWidth : charInfo.offsetX; + this.originY = charInfo.offsetY; + this.height = glyphAtlas.height; + this.width = j === 0 || j === charArr.length - 1 ? + glyphAtlas.width[charArr[j]] + lineWidth : glyphAtlas.width[charArr[j]]; + this.imageHeight = image.height; + this.imageWidth = image.width; + + var currentImage; + if (this.images_.length === 0) { + this.images_.push(image); + } else { + currentImage = this.images_[this.images_.length - 1]; + if (ol.getUid(currentImage) != ol.getUid(image)) { + this.groupIndices.push(this.indices.length); + this.images_.push(image); + } + } + + this.drawText_(flatCoordinates, offset, end, stride); + } + currX += this.width; + } + } } }; /** * @private - * @return {HTMLCanvasElement} Text image. + * @param {Array.} lines Label to draw split to lines. + * @return {Array.} Size of the label in pixels. */ - ol.render.webgl.TextReplay.prototype.createTextImage_ = function() { - var state = this.state_; - - //Measure text dimensions - var mCtx = this.measureCanvas_.getContext('2d'); - mCtx.font = state.font; - var lineHeight = Math.round(mCtx.measureText('M').width * 1.2 + state.lineWidth * 2); - var lines = state.text.split('\n'); - //FIXME: use pixelRatio - var textHeight = Math.ceil(lineHeight * lines.length * state.scale); - this.height = textHeight; - this.imageHeight = textHeight; - this.anchorY = Math.round(textHeight * state.textBaseline + state.offsetY); - var longestLine = lines.map(function(str) { - return mCtx.measureText(str).width; + ol.render.webgl.TextReplay.prototype.getTextSize_ = function(lines) { + var self = this; + var glyphAtlas = this.currAtlas_; + var textHeight = lines.length * glyphAtlas.height; + //Split every line to an array of chars, sum up their width, and select the longest. + var textWidth = lines.map(function(str) { + var sum = 0; + var i, ii; + for (i = 0, ii = str.length; i < ii; ++i) { + var curr = str[i]; + if (!glyphAtlas.width[curr]) { + self.addCharToAtlas_(curr); + } + sum += glyphAtlas.width[curr] ? glyphAtlas.width[curr] : 0; + } + return sum; }).reduce(function(max, curr) { return Math.max(max, curr); }); - //FIXME: use pixelRatio - var textWidth = Math.ceil((longestLine + state.lineWidth * 2) * state.scale); - this.width = textWidth; - this.imageWidth = textWidth; - this.anchorX = Math.round(textWidth * state.textAlign + state.offsetX); - //Create a canvas - var ctx = ol.dom.createCanvasContext2D(textWidth, textHeight); - var canvas = ctx.canvas; + return [textWidth, textHeight]; + }; - //Parameterize the canvas - ctx.font = /** @type {string} */ (state.font); - ctx.fillStyle = state.fillColor; - ctx.strokeStyle = state.strokeColor; - ctx.lineWidth = state.lineWidth; - ctx.lineCap = /*** @type {string} */ (state.lineCap); - ctx.lineJoin = /** @type {string} */ (state.lineJoin); - ctx.miterLimit = /** @type {number} */ (state.miterLimit); - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - if (ol.has.CANVAS_LINE_DASH && state.lineDash) { - //FIXME: use pixelRatio - ctx.setLineDash(state.lineDash); - ctx.lineDashOffset = /** @type {number} */ (state.lineDashOffset); - } - if (state.scale !== 1) { - //FIXME: use pixelRatio - ctx.setTransform(/** @type {number} */ (state.scale), 0, 0, - /** @type {number} */ (state.scale), 0, 0); + + /** + * @private + * @param {Array.} flatCoordinates Flat coordinates. + * @param {number} offset Offset. + * @param {number} end End. + * @param {number} stride Stride. + */ + ol.render.webgl.TextReplay.prototype.drawText_ = function(flatCoordinates, offset, + end, stride) { + var i, ii; + for (i = offset, ii = end; i < ii; i += stride) { + this.drawCoordinates(flatCoordinates, offset, end, stride); } + }; - //Draw the text on the canvas - var lineY = 0; - for (var i = 0, ii = lines.length; i < ii; ++i) { - if (state.strokeColor) { - ctx.strokeText(lines[i], 0, lineY); + + /** + * @private + * @param {string} char Character. + */ + ol.render.webgl.TextReplay.prototype.addCharToAtlas_ = function(char) { + if (char.length === 1) { + var glyphAtlas = this.currAtlas_; + var state = this.state_; + var mCtx = this.measureCanvas_.getContext('2d'); + mCtx.font = state.font; + var width = Math.ceil(mCtx.measureText(char).width * state.scale); + + var info = glyphAtlas.atlas.add(char, width, glyphAtlas.height, + function(ctx, x, y) { + //Parameterize the canvas + ctx.font = /** @type {string} */ (state.font); + ctx.fillStyle = state.fillColor; + ctx.strokeStyle = state.strokeColor; + ctx.lineWidth = state.lineWidth; + ctx.lineCap = /*** @type {string} */ (state.lineCap); + ctx.lineJoin = /** @type {string} */ (state.lineJoin); + ctx.miterLimit = /** @type {number} */ (state.miterLimit); + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + if (ol.has.CANVAS_LINE_DASH && state.lineDash) { + //FIXME: use pixelRatio + ctx.setLineDash(state.lineDash); + ctx.lineDashOffset = /** @type {number} */ (state.lineDashOffset); + } + if (state.scale !== 1) { + //FIXME: use pixelRatio + ctx.setTransform(/** @type {number} */ (state.scale), 0, 0, + /** @type {number} */ (state.scale), 0, 0); + } + + //Draw the character on the canvas + if (state.strokeColor) { + ctx.strokeText(char, x, y); + } + if (state.fillColor) { + ctx.fillText(char, x, y); + } + }); + + if (info) { + glyphAtlas.width[char] = width; } - if (state.fillColor) { - ctx.fillText(lines[i], 0, lineY); - } - lineY += lineHeight; } - - return /** @type {HTMLCanvasElement} */ (canvas); }; @@ -204,15 +300,17 @@ if (ol.ENABLE_WEBGL) { lineWidth: 0, miterLimit: undefined, fillColor: null, - text: '', font: undefined, - textAlign: undefined, - textBaseline: undefined, - offsetX: undefined, - offsetY: undefined, scale: undefined }; + this.text_ = ''; + this.textAlign_ = undefined; + this.textBaseline_ = undefined; + this.offsetX_ = undefined; + this.offsetY_ = undefined; this.images_ = null; + this.atlases_ = {}; + this.currAtlas_ = undefined; ol.render.webgl.TextureReplay.prototype.finish.call(this, context); }; @@ -225,7 +323,7 @@ if (ol.ENABLE_WEBGL) { var textFillStyle = textStyle.getFill(); var textStrokeStyle = textStyle.getStroke(); if (!textStyle || !textStyle.getText() || (!textFillStyle && !textStrokeStyle)) { - state.text = ''; + this.text_ = ''; } else { if (!textFillStyle) { state.fillColor = null; @@ -251,25 +349,72 @@ if (ol.ENABLE_WEBGL) { } state.font = textStyle.getFont() || ol.render.webgl.defaultFont; state.scale = textStyle.getScale() || 1; - state.text = textStyle.getText(); + this.text_ = /** @type {string} */ (textStyle.getText()); var textAlign = ol.render.webgl.TextReplay.Align_[textStyle.getTextAlign()]; var textBaseline = ol.render.webgl.TextReplay.Align_[textStyle.getTextBaseline()]; - state.textAlign = textAlign === undefined ? + this.textAlign_ = textAlign === undefined ? ol.render.webgl.defaultTextAlign : textAlign; - state.textBaseline = textBaseline === undefined ? + this.textBaseline_ = textBaseline === undefined ? ol.render.webgl.defaultTextBaseline : textBaseline; - state.offsetX = textStyle.getOffsetX() || 0; - state.offsetY = textStyle.getOffsetY() || 0; + this.offsetX_ = textStyle.getOffsetX() || 0; + this.offsetY_ = textStyle.getOffsetY() || 0; this.rotateWithView = !!textStyle.getRotateWithView(); this.rotation = textStyle.getRotation() || 0; - if (this.images_.length === 0) { - this.images_.push(this.createTextImage_()); - } else { - this.groupIndices.push(this.indices.length); - this.images_.push(this.createTextImage_()); + this.currAtlas_ = this.getAtlas_(state); + } + }; + + + /** + * @private + * @param {Object} state Font attributes. + * @return {ol.WebglGlyphAtlas} Glyph atlas. + */ + ol.render.webgl.TextReplay.prototype.getAtlas_ = function(state) { + var params = []; + var i; + for (i in state) { + if (state[i] || state[i] === 0) { + if (Array.isArray(state[i])) { + params = params.concat(state[i]); + } else { + params.push(state[i]); + } } } + var hash = this.calculateHash_(params); + if (!this.atlases_[hash]) { + var mCtx = this.measureCanvas_.getContext('2d'); + mCtx.font = state.font; + var height = Math.ceil((mCtx.measureText('M').width * 1.5 + + state.lineWidth / 2) * state.scale); + + this.atlases_[hash] = { + atlas: new ol.style.AtlasManager({ + space: state.lineWidth + 1 + }), + width: {}, + height: height + }; + } + return this.atlases_[hash]; + }; + + + /** + * @private + * @param {Array.} params Array of parameters. + * @return {string} Hash string. + */ + ol.render.webgl.TextReplay.prototype.calculateHash_ = function(params) { + //TODO: Create a more performant, reliable, general hash function. + var i, ii; + var hash = ''; + for (i = 0, ii = params.length; i < ii; ++i) { + hash += params[i]; + } + return hash; }; diff --git a/src/ol/typedefs.js b/src/ol/typedefs.js index 65de15bce1..8581394c33 100644 --- a/src/ol/typedefs.js +++ b/src/ol/typedefs.js @@ -723,6 +723,14 @@ ol.ViewAnimation; ol.WebglBufferCacheEntry; +/** + * @typedef {{atlas: ol.style.AtlasManager, + * width: Object., + * height: number}} + */ +ol.WebglGlyphAtlas; + + /** * @typedef {{p0: ol.WebglPolygonVertex, * p1: ol.WebglPolygonVertex}} diff --git a/test/spec/ol/render/webgl/textreplay.test.js b/test/spec/ol/render/webgl/textreplay.test.js index 6482820dbb..610e49a02b 100644 --- a/test/spec/ol/render/webgl/textreplay.test.js +++ b/test/spec/ol/render/webgl/textreplay.test.js @@ -75,28 +75,15 @@ describe('ol.render.webgl.TextReplay', function() { }); it('set expected states', function() { - var mCtx = ol.dom.createCanvasContext2D(0, 0); - mCtx.font = '12px Arial'; - var mWidth = mCtx.measureText('M').width; - var textWidth = mCtx.measureText('someText').width; - var width = Math.ceil((textWidth + 2) * 2); - var height = Math.ceil(Math.round(mWidth * 1.2 + 2) * 2); - replay.setTextStyle(textStyle1); - expect(replay.anchorX).to.be(10); - expect(replay.anchorY).to.be(10); - expect(replay.height).to.be(height); - expect(replay.imageHeight).to.be(height); - expect(replay.width).to.be(width); - expect(replay.imageWidth).to.be(width); expect(replay.opacity).to.be(1); - expect(replay.originX).to.be(0); - expect(replay.originY).to.be(0); expect(replay.rotation).to.be(1.5); expect(replay.rotateWithView).to.be(true); expect(replay.scale).to.be(1); - expect(replay.images_).to.have.length(1); - expect(replay.groupIndices).to.have.length(0); + expect(replay.offsetX_).to.be(10); + expect(replay.offsetY_).to.be(10); + expect(replay.text_).to.be('someText'); + expect(Object.keys(replay.atlases_)).to.have.length(1); expect(replay.state_.fillColor).to.be('rgba(0,0,0,1)'); expect(replay.state_.strokeColor).to.be('rgba(0,0,0,1)'); @@ -108,25 +95,21 @@ describe('ol.render.webgl.TextReplay', function() { expect(replay.state_.lineDashOffset).to.be(15); expect(replay.state_.miterLimit).to.be(2); expect(replay.state_.font).to.be('12px Arial'); - expect(replay.state_.text).to.be('someText'); replay.setTextStyle(textStyle2); - expect(replay.images_).to.have.length(2); - expect(replay.groupIndices).to.have.length(1); + expect(Object.keys(replay.atlases_)).to.have.length(2); }); - it('does not create an image, if an empty text is supplied', function() { + it('does not create an atlas, if an empty text is supplied', function() { replay.setTextStyle(textStyle4); - expect(replay.state_.text).to.be(''); - expect(replay.images_).to.have.length(0); - expect(replay.groupIndices).to.have.length(0); + expect(replay.text_).to.be(''); + expect(Object.keys(replay.atlases_)).to.have.length(0); }); - it('does not create an image, if both fill and stroke styles are missing', function() { + it('does not create an atlas, if both fill and stroke styles are missing', function() { replay.setTextStyle(textStyle3); - expect(replay.state_.text).to.be(''); - expect(replay.images_).to.have.length(0); - expect(replay.groupIndices).to.have.length(0); + expect(replay.text_).to.be(''); + expect(Object.keys(replay.atlases_)).to.have.length(0); }); }); @@ -145,29 +128,38 @@ describe('ol.render.webgl.TextReplay', function() { point = [1000, 2000]; replay.drawText(point, 0, 2, 2, null, null); - expect(replay.vertices).to.have.length(32); - expect(replay.indices).to.have.length(6); - expect(replay.indices[0]).to.be(0); - expect(replay.indices[1]).to.be(1); - expect(replay.indices[2]).to.be(2); - expect(replay.indices[3]).to.be(0); - expect(replay.indices[4]).to.be(2); - expect(replay.indices[5]).to.be(3); + expect(replay.vertices).to.have.length(256); + expect(replay.indices).to.have.length(48); point = [2000, 3000]; replay.drawText(point, 0, 2, 2, null, null); - expect(replay.vertices).to.have.length(64); - expect(replay.indices).to.have.length(12); - expect(replay.indices[6]).to.be(4); - expect(replay.indices[7]).to.be(5); - expect(replay.indices[8]).to.be(6); - expect(replay.indices[9]).to.be(4); - expect(replay.indices[10]).to.be(6); - expect(replay.indices[11]).to.be(7); + expect(replay.vertices).to.have.length(512); + expect(replay.indices).to.have.length(96); + }); + + it('sets part of its state during drawing', function() { + var point = [1000, 2000]; + replay.drawText(point, 0, 2, 2, null, null); + + var height = replay.currAtlas_.height; + var widths = replay.currAtlas_.width; + var width = widths.t; + var widthX = widths.s + widths.o + widths.m + widths.e + widths.T + + widths.e + widths.x; + var charInfo = replay.currAtlas_.atlas.getInfo('t'); + + expect(replay.height).to.be(height); + expect(replay.width).to.be(width); + expect(replay.originX).to.be(charInfo.offsetX); + expect(replay.originY).to.be(charInfo.offsetY); + expect(replay.imageHeight).to.be(charInfo.image.height); + expect(replay.imageWidth).to.be(charInfo.image.width); + expect(replay.anchorX).to.be(-widthX + 10); + expect(replay.anchorY).to.be(10); }); it('does not draw if text is empty', function() { - replay.state_.text = ''; + replay.text_ = ''; var point; point = [1000, 2000]; @@ -177,6 +169,126 @@ describe('ol.render.webgl.TextReplay', function() { }); }); + describe('#addCharToAtlas_', function() { + beforeEach(function() { + var textStyle = createTextStyle( + new ol.style.Fill({ + color: [0, 0, 0, 1] + }), + null, 'someText'); + replay.setTextStyle(textStyle); + }); + + it('adds a single character to the current atlas', function() { + var glyphAtlas = replay.currAtlas_.atlas; + var info; + + replay.addCharToAtlas_('someText'); + info = glyphAtlas.getInfo('someText'); + expect(info).to.be(null); + + replay.addCharToAtlas_('e'); + replay.addCharToAtlas_('x'); + info = glyphAtlas.getInfo('e'); + expect(info).not.to.be(null); + info = glyphAtlas.getInfo('x'); + expect(info).not.to.be(null); + }); + + it('keeps the atlas and the width dictionary synced', function() { + var glyphAtlas = replay.currAtlas_; + + replay.addCharToAtlas_('e'); + replay.addCharToAtlas_('x'); + expect(Object.keys(glyphAtlas.width)).to.have.length(2); + + replay.addCharToAtlas_('someText'); + expect(Object.keys(glyphAtlas.width)).to.have.length(2); + }); + }); + + describe('#getTextSize_', function() { + beforeEach(function() { + var textStyle = createTextStyle( + new ol.style.Fill({ + color: [0, 0, 0, 1] + }), + null, 'someText'); + textStyle.setScale(1); + replay.setTextStyle(textStyle); + }); + + it('adds missing characters to the current atlas', function() { + var glyphAtlas = replay.currAtlas_; + var info; + + expect(Object.keys(glyphAtlas.width)).to.have.length(0); + replay.getTextSize_(['someText']); + expect(Object.keys(glyphAtlas.width)).to.have.length(7); + info = glyphAtlas.atlas.getInfo('s'); + expect(info).not.to.be(null); + info = glyphAtlas.atlas.getInfo('o'); + expect(info).not.to.be(null); + info = glyphAtlas.atlas.getInfo('m'); + expect(info).not.to.be(null); + info = glyphAtlas.atlas.getInfo('e'); + expect(info).not.to.be(null); + info = glyphAtlas.atlas.getInfo('T'); + expect(info).not.to.be(null); + info = glyphAtlas.atlas.getInfo('x'); + expect(info).not.to.be(null); + info = glyphAtlas.atlas.getInfo('t'); + expect(info).not.to.be(null); + }); + + it('returns the size of the label\'s bounding box in pixels', function() { + var size; + var mCtx = ol.dom.createCanvasContext2D(0, 0); + mCtx.font = '12px Arial'; + var width = mCtx.measureText('someText').width; + var width2 = mCtx.measureText('anEvenLongerLine').width; + var height = Math.ceil(mCtx.measureText('M').width * 1.5); + + size = replay.getTextSize_(['someText']); + expect(size[0]).to.be.within(width, width + 8); + expect(size[1]).to.be(height); + + size = replay.getTextSize_(['someText', 'anEvenLongerLine']); + expect(size[0]).to.be.within(width2, width2 + 16); + expect(size[1]).to.be(height * 2); + }); + }); + + describe('#getAtlas_', function() { + beforeEach(function() { + var textStyle = createTextStyle( + new ol.style.Fill({ + color: [0, 0, 0, 1] + }), + null, 'someText'); + replay.setTextStyle(textStyle); + }); + + it('returns the appropriate atlas for the current state', function() { + var atlas = replay.currAtlas_; + var state = replay.state_; + + expect(Object.keys(replay.atlases_)).to.have.length(1); + expect(replay.getAtlas_(state)).to.be(atlas); + expect(Object.keys(replay.atlases_)).to.have.length(1); + }); + + it('creates a new atlas if it cannot find the one for the current state', function() { + var atlas = replay.currAtlas_; + var state = replay.state_; + state.lineWidth = 50; + + expect(Object.keys(replay.atlases_)).to.have.length(1); + expect(replay.getAtlas_(state)).not.to.be(atlas); + expect(Object.keys(replay.atlases_)).to.have.length(2); + }); + }); + describe('#getTextures', function() { beforeEach(function() { replay.textures_ = [1, 2]; diff --git a/test_rendering/spec/ol/style/text.test.js b/test_rendering/spec/ol/style/text.test.js index d32cb4c380..0dbf7dbf5e 100644 --- a/test_rendering/spec/ol/style/text.test.js +++ b/test_rendering/spec/ol/style/text.test.js @@ -105,14 +105,14 @@ describe('ol.rendering.style.Text', function() { it('tests the webgl renderer without rotation', function(done) { map = createMap('webgl'); createFeatures(); - expectResemble(map, 'spec/ol/style/expected/text-webgl.png', IMAGE_TOLERANCE, done); + expectResemble(map, 'spec/ol/style/expected/text-webgl.png', 1.8, done); }); it('tests the webgl renderer with rotation', function(done) { map = createMap('webgl'); createFeatures(); map.getView().setRotation(Math.PI / 7); - expectResemble(map, 'spec/ol/style/expected/text-rotated-webgl.png', 1.6, done); + expectResemble(map, 'spec/ol/style/expected/text-rotated-webgl.png', 1.8, done); }); });