import * as util from './webgl-util.mjs'; import { mat3 } from 'gl-matrix'; import { AtlasManager, AtlasBatchManager } from './atlas.mjs'; import * as math from '../../../../math.mjs'; import * as sdf from './shader-sdf.mjs'; /** * Two render modes. Each mode has its own shader program. They are almost identical, the main difference is the output. * SCREEN: output pixel colors to the screen * PICKING: output z-order index to an offscreen framebuffer, used to detect what's under the mouse cursor */ export const RENDER_TARGET = { SCREEN: { name: 'screen', screen: true }, PICKING: { name: 'picking', picking: true }, }; /** * Special handing for label textures in PICKING mode. See issue #3337. */ export const TEX_PICKING_MODE = { NORMAL: 0, // render the texture just like in RENDER_TARGET.SCREEN mode IGNORE: 1, // don't render the texture at all USE_BB: 2 // render the bounding box as an opaque rectangle } // Vertex types. // Used directly in the shaders so must be numeric. // There is only one shader program used for an entire frame that renders all types of elements. // There are if-else blocks in the shaders that do different things depending on the vertex type. // This allows all elements to be rendererd in large batches without switching shader programs. const TEXTURE = 0; const EDGE_STRAIGHT = 1; const EDGE_CURVE_SEGMENT = 2; const EDGE_ARROW = 3; const RECTANGLE = 4; const ROUND_RECTANGLE = 5; const BOTTOM_ROUND_RECTANGLE = 6; const ELLIPSE = 7; export class ElementDrawingWebGL { /** * @param {WebGLRenderingContext} gl */ constructor(r, gl, opts) { this.r = r; // reference to the canvas renderer this.gl = gl; this.maxInstances = opts.webglBatchSize; this.atlasSize = opts.webglTexSize; this.bgColor = opts.bgColor; this.debug = opts.webglDebug; this.batchDebugInfo = []; opts.enableWrapping = true; opts.createTextureCanvas = util.createTextureCanvas; // Unit tests mock this this.atlasManager = new AtlasManager(r, opts); this.batchManager = new AtlasBatchManager(opts); this.simpleShapeOptions = new Map(); this.program = this._createShaderProgram(RENDER_TARGET.SCREEN); this.pickingProgram = this._createShaderProgram(RENDER_TARGET.PICKING); this.vao = this._createVAO(); } /** * @param { string } collectionName * @param {{ texRows: number }} opts */ addAtlasCollection(collectionName, opts) { this.atlasManager.addAtlasCollection(collectionName, opts); } /** * @typedef { Object } TextureRenderTypeOpts * @property { string } collection - name of atlas collection to render textures to * @property { function } getKey - returns the "style key" for an element, may be a single value or an array for multi-line lables * @property { function } drawElement - uses a canvas renderer to draw the element to the texture atlas * @property { boolean } drawClipped - if true the context will be clipped to the bounding box before drawElement() is called, may affect performance * @property { function } getBoundingBox - returns the bounding box for an element * @property { function } getRotation * @property { function } getRotationPoint * @property { function } getRotationOffset * @property { function } isVisible - an extra check for visibility in addition to ele.visible() * @property { function } getTexPickingMode - returns a value from the TEX_PICKING_MODE enum */ /** * @param { string } typeName * @param { TextureRenderTypeOpts } opts */ addTextureAtlasRenderType(typeName, opts) { this.atlasManager.addRenderType(typeName, opts); } /** * @typedef { Object } SimpleShapeRenderTypeOpts * @property { function } getBoundingBox - returns the bounding box for an element * @property { function } isVisible - this is an extra check for visibility in addition to ele.visible() * @property { function } isSimple - check if element is a simple shape, or if it needs to fall back to texture rendering * @property { ShapeVisualProperties } shapeProps */ /** * @typedef { Object } ShapeVisualProperties * @property { string } shape * @property { string } color * @property { string } opacity * @property { string } padding * @property { string } radius * @property { boolean } border */ /** * @param { string } typeName * @param { SimpleShapeRenderTypeOpts } opts */ addSimpleShapeRenderType(typeName, opts) { this.simpleShapeOptions.set(typeName, opts); } /** * Inform the atlasManager when element style keys may have changed. * The atlasManager can then mark unused textures for "garbage collection". */ invalidate(eles, { type } = {}) { const { atlasManager } = this; if(type) { return atlasManager.invalidate(eles, { filterType: t => t === type, forceRedraw: true }); } else { return atlasManager.invalidate(eles); } } /** * Run texture garbage collection. */ gc() { this.atlasManager.gc(); } _createShaderProgram(renderTarget) { const { gl } = this; const vertexShaderSource = `#version 300 es precision highp float; uniform mat3 uPanZoomMatrix; uniform int uAtlasSize; // instanced in vec2 aPosition; // a vertex from the unit square in mat3 aTransform; // used to transform verticies, eg into a bounding box in int aVertType; // the type of thing we are rendering // the z-index that is output when using picking mode in vec4 aIndex; // For textures in int aAtlasId; // which shader unit/atlas to use in vec4 aTex; // x/y/w/h of texture in atlas // for edges in vec4 aPointAPointB; in vec4 aPointCPointD; in vec2 aLineWidth; // also used for node border width // simple shapes in vec4 aCornerRadius; // for round-rectangle [top-right, bottom-right, top-left, bottom-left] in vec4 aColor; // also used for edges in vec4 aBorderColor; // aLineWidth is used for border width // output values passed to the fragment shader out vec2 vTexCoord; out vec4 vColor; out vec2 vPosition; // flat values are not interpolated flat out int vAtlasId; flat out int vVertType; flat out vec2 vTopRight; flat out vec2 vBotLeft; flat out vec4 vCornerRadius; flat out vec4 vBorderColor; flat out vec2 vBorderWidth; flat out vec4 vIndex; void main(void) { int vid = gl_VertexID; vec2 position = aPosition; // TODO make this a vec3, simplifies some code below if(aVertType == ${TEXTURE}) { float texX = aTex.x; // texture coordinates float texY = aTex.y; float texW = aTex.z; float texH = aTex.w; if(vid == 1 || vid == 2 || vid == 4) { texX += texW; } if(vid == 2 || vid == 4 || vid == 5) { texY += texH; } float d = float(uAtlasSize); vTexCoord = vec2(texX / d, texY / d); // tex coords must be between 0 and 1 gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); } else if(aVertType == ${RECTANGLE} || aVertType == ${ELLIPSE} || aVertType == ${ROUND_RECTANGLE} || aVertType == ${BOTTOM_ROUND_RECTANGLE}) { // simple shapes // the bounding box is needed by the fragment shader vBotLeft = (aTransform * vec3(0, 0, 1)).xy; // flat vTopRight = (aTransform * vec3(1, 1, 1)).xy; // flat vPosition = (aTransform * vec3(position, 1)).xy; // will be interpolated // calculations are done in the fragment shader, just pass these along vColor = aColor; vCornerRadius = aCornerRadius; vBorderColor = aBorderColor; vBorderWidth = aLineWidth; gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); } else if(aVertType == ${EDGE_STRAIGHT}) { vec2 source = aPointAPointB.xy; vec2 target = aPointAPointB.zw; // adjust the geometry so that the line is centered on the edge position.y = position.y - 0.5; // stretch the unit square into a long skinny rectangle vec2 xBasis = target - source; vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); vec2 point = source + xBasis * position.x + yBasis * aLineWidth[0] * position.y; gl_Position = vec4(uPanZoomMatrix * vec3(point, 1.0), 1.0); vColor = aColor; } else if(aVertType == ${EDGE_CURVE_SEGMENT}) { vec2 pointA = aPointAPointB.xy; vec2 pointB = aPointAPointB.zw; vec2 pointC = aPointCPointD.xy; vec2 pointD = aPointCPointD.zw; // adjust the geometry so that the line is centered on the edge position.y = position.y - 0.5; vec2 p0, p1, p2, pos; if(position.x == 0.0) { // The left side of the unit square p0 = pointA; p1 = pointB; p2 = pointC; pos = position; } else { // The right side of the unit square, use same approach but flip the geometry upside down p0 = pointD; p1 = pointC; p2 = pointB; pos = vec2(0.0, -position.y); } vec2 p01 = p1 - p0; vec2 p12 = p2 - p1; vec2 p21 = p1 - p2; // Find the normal vector. vec2 tangent = normalize(normalize(p12) + normalize(p01)); vec2 normal = vec2(-tangent.y, tangent.x); // Find the vector perpendicular to p0 -> p1. vec2 p01Norm = normalize(vec2(-p01.y, p01.x)); // Determine the bend direction. float sigma = sign(dot(p01 + p21, normal)); float width = aLineWidth[0]; if(sign(pos.y) == -sigma) { // This is an intersecting vertex. Adjust the position so that there's no overlap. vec2 point = 0.5 * width * normal * -sigma / dot(normal, p01Norm); gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); } else { // This is a non-intersecting vertex. Treat it like a mitre join. vec2 point = 0.5 * width * normal * sigma * dot(normal, p01Norm); gl_Position = vec4(uPanZoomMatrix * vec3(p1 + point, 1.0), 1.0); } vColor = aColor; } else if(aVertType == ${EDGE_ARROW} && vid < 3) { // massage the first triangle into an edge arrow if(vid == 0) position = vec2(-0.15, -0.3); if(vid == 1) position = vec2( 0.0, 0.0); if(vid == 2) position = vec2( 0.15, -0.3); gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0); vColor = aColor; } else { gl_Position = vec4(2.0, 0.0, 0.0, 1.0); // discard vertex by putting it outside webgl clip space } vAtlasId = aAtlasId; vVertType = aVertType; vIndex = aIndex; } `; const idxs = this.batchManager.getIndexArray(); const fragmentShaderSource = `#version 300 es precision highp float; // declare texture unit for each texture atlas in the batch ${idxs.map(i => `uniform sampler2D uTexture${i};`).join('\n\t')} uniform vec4 uBGColor; uniform float uZoom; in vec2 vTexCoord; in vec4 vColor; in vec2 vPosition; // model coordinates flat in int vAtlasId; flat in vec4 vIndex; flat in int vVertType; flat in vec2 vTopRight; flat in vec2 vBotLeft; flat in vec4 vCornerRadius; flat in vec4 vBorderColor; flat in vec2 vBorderWidth; out vec4 outColor; ${sdf.circleSD} ${sdf.rectangleSD} ${sdf.roundRectangleSD} ${sdf.ellipseSD} vec4 blend(vec4 top, vec4 bot) { // blend colors with premultiplied alpha return vec4( top.rgb + (bot.rgb * (1.0 - top.a)), top.a + (bot.a * (1.0 - top.a)) ); } vec4 distInterp(vec4 cA, vec4 cB, float d) { // interpolate color using Signed Distance // scale to the zoom level so that borders don't look blurry when zoomed in // note 1.5 is an aribitrary value chosen because it looks good return mix(cA, cB, 1.0 - smoothstep(0.0, 1.5 / uZoom, abs(d))); } void main(void) { if(vVertType == ${TEXTURE}) { // look up the texel from the texture unit ${idxs.map(i => `if(vAtlasId == ${i}) outColor = texture(uTexture${i}, vTexCoord);`).join('\n\telse ')} } else if(vVertType == ${EDGE_ARROW}) { // mimics how canvas renderer uses context.globalCompositeOperation = 'destination-out'; outColor = blend(vColor, uBGColor); outColor.a = 1.0; // make opaque, masks out line under arrow } else if(vVertType == ${RECTANGLE} && vBorderWidth == vec2(0.0)) { // simple rectangle with no border outColor = vColor; // unit square is already transformed to the rectangle, nothing else needs to be done } else if(vVertType == ${RECTANGLE} || vVertType == ${ELLIPSE} || vVertType == ${ROUND_RECTANGLE} || vVertType == ${BOTTOM_ROUND_RECTANGLE}) { // use SDF float outerBorder = vBorderWidth[0]; float innerBorder = vBorderWidth[1]; float borderPadding = outerBorder * 2.0; float w = vTopRight.x - vBotLeft.x - borderPadding; float h = vTopRight.y - vBotLeft.y - borderPadding; vec2 b = vec2(w/2.0, h/2.0); // half width, half height vec2 p = vPosition - vec2(vTopRight.x - b[0] - outerBorder, vTopRight.y - b[1] - outerBorder); // translate to center float d; // signed distance if(vVertType == ${RECTANGLE}) { d = rectangleSD(p, b); } else if(vVertType == ${ELLIPSE} && w == h) { d = circleSD(p, b.x); // faster than ellipse } else if(vVertType == ${ELLIPSE}) { d = ellipseSD(p, b); } else { d = roundRectangleSD(p, b, vCornerRadius.wzyx); } // use the distance to interpolate a color to smooth the edges of the shape, doesn't need multisampling // we must smooth colors inwards, because we can't change pixels outside the shape's bounding box if(d > 0.0) { if(d > outerBorder) { discard; } else { outColor = distInterp(vBorderColor, vec4(0), d - outerBorder); } } else { if(d > innerBorder) { vec4 outerColor = outerBorder == 0.0 ? vec4(0) : vBorderColor; vec4 innerBorderColor = blend(vBorderColor, vColor); outColor = distInterp(innerBorderColor, outerColor, d); } else { vec4 outerColor; if(innerBorder == 0.0 && outerBorder == 0.0) { outerColor = vec4(0); } else if(innerBorder == 0.0) { outerColor = vBorderColor; } else { outerColor = blend(vBorderColor, vColor); } outColor = distInterp(vColor, outerColor, d - innerBorder); } } } else { outColor = vColor; } ${ renderTarget.picking ? `if(outColor.a == 0.0) discard; else outColor = vIndex;` : '' } } `; const program = util.createProgram(gl, vertexShaderSource, fragmentShaderSource); // instance geometry program.aPosition = gl.getAttribLocation(program, 'aPosition'); // attributes program.aIndex = gl.getAttribLocation(program, 'aIndex'); program.aVertType = gl.getAttribLocation(program, 'aVertType'); program.aTransform = gl.getAttribLocation(program, 'aTransform'); program.aAtlasId = gl.getAttribLocation(program, 'aAtlasId'); program.aTex = gl.getAttribLocation(program, 'aTex'); program.aPointAPointB = gl.getAttribLocation(program, 'aPointAPointB'); program.aPointCPointD = gl.getAttribLocation(program, 'aPointCPointD'); program.aLineWidth = gl.getAttribLocation(program, 'aLineWidth'); program.aColor = gl.getAttribLocation(program, 'aColor'); program.aCornerRadius = gl.getAttribLocation(program, 'aCornerRadius'); program.aBorderColor = gl.getAttribLocation(program, 'aBorderColor'); // uniforms program.uPanZoomMatrix = gl.getUniformLocation(program, 'uPanZoomMatrix'); program.uAtlasSize = gl.getUniformLocation(program, 'uAtlasSize'); program.uBGColor = gl.getUniformLocation(program, 'uBGColor'); program.uZoom = gl.getUniformLocation(program, 'uZoom'); program.uTextures = []; for(let i = 0; i < this.batchManager.getMaxAtlasesPerBatch(); i++) { program.uTextures.push(gl.getUniformLocation(program, `uTexture${i}`)); } return program; } _createVAO() { const unitSquare = [ 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, ]; this.vertexCount = unitSquare.length / 2; const n = this.maxInstances; const { gl, program } = this; const vao = gl.createVertexArray(); gl.bindVertexArray(vao); util.createBufferStaticDraw(gl, 'vec2', program.aPosition, unitSquare); // Create buffers for all the attributes this.transformBuffer = util.create3x3MatrixBufferDynamicDraw(gl, n, program.aTransform); this.indexBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aIndex); this.vertTypeBuffer = util.createBufferDynamicDraw(gl, n, 'int', program.aVertType); this.atlasIdBuffer = util.createBufferDynamicDraw(gl, n, 'int', program.aAtlasId); this.texBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aTex); this.pointAPointBBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aPointAPointB); this.pointCPointDBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aPointCPointD); this.lineWidthBuffer = util.createBufferDynamicDraw(gl, n, 'vec2', program.aLineWidth); this.colorBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aColor); this.cornerRadiusBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aCornerRadius); this.borderColorBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aBorderColor); gl.bindVertexArray(null); return vao; } get buffers() { if(!this._buffers) { this._buffers = Object.keys(this).filter(k => k.endsWith('Buffer')).map(k => this[k]); } return this._buffers; } startFrame(panZoomMatrix, renderTarget = RENDER_TARGET.SCREEN) { this.panZoomMatrix = panZoomMatrix; this.renderTarget = renderTarget; this.batchDebugInfo = []; this.wrappedCount = 0; this.simpleCount = 0; this.startBatch(); } startBatch() { this.instanceCount = 0; this.batchManager.startBatch(); } endFrame() { this.endBatch(); } _isVisible(ele, opts) { if(ele.visible()) { if(opts && opts.isVisible) { return opts.isVisible(ele); } return true; } return false; } /** * Draws a texture using the texture atlas. */ drawTexture(ele, eleIndex, type) { const { atlasManager, batchManager } = this; const opts = atlasManager.getRenderTypeOpts(type); if(!this._isVisible(ele, opts)) { return; } if(this.renderTarget.picking && opts.getTexPickingMode) { const mode = opts.getTexPickingMode(ele); if(mode === TEX_PICKING_MODE.IGNORE) { return; } else if(mode == TEX_PICKING_MODE.USE_BB) { this.drawPickingRectangle(ele, eleIndex, type); return; } } // Get the atlas and the texture coordinates, will draw the texture if it hasn't been drawn yet // May be more than one texture if for example the label has multiple lines const atlasInfoArray = atlasManager.getAtlasInfo(ele, type); for(const atlasInfo of atlasInfoArray) { const { atlas, tex1, tex2 } = atlasInfo; // tex2 is used if the label wraps and there are two textures if(!batchManager.canAddToCurrentBatch(atlas)) { this.endBatch(); } const atlasIndex = batchManager.getAtlasIndexForBatch(atlas); for(const [tex, first] of [[tex1, true], [tex2, false]]) { if(tex.w != 0) { const instance = this.instanceCount; this.vertTypeBuffer.getView(instance)[0] = TEXTURE; const indexView = this.indexBuffer.getView(instance); util.indexToVec4(eleIndex, indexView); // Set values in the buffers using Typed Array Views for performance. const atlasIdView = this.atlasIdBuffer.getView(instance); atlasIdView[0] = atlasIndex; // we have two sets of texture coordinates and transforms because textures can wrap in the atlas const texView = this.texBuffer.getView(instance); texView[0] = tex.x; texView[1] = tex.y; texView[2] = tex.w; texView[3] = tex.h; const matrixView = this.transformBuffer.getMatrixView(instance); this.setTransformMatrix(ele, matrixView, opts, atlasInfo, first); this.instanceCount++; if(!first) this.wrappedCount++; if(this.instanceCount >= this.maxInstances) { this.endBatch(); } } } } } /** * matrix is expected to be a 9 element array * this function follows same pattern as CRp.drawCachedElementPortion(...) */ setTransformMatrix(ele, matrix, opts, atlasInfo, first=true) { let padding = 0; if(opts.shapeProps && opts.shapeProps.padding) { padding = ele.pstyle(opts.shapeProps.padding).pfValue; } if(atlasInfo) { // we've already computed the bb and tex bounds for a texture const { bb, tex1, tex2 } = atlasInfo; // wrapped textures need separate matrix for each part let ratio = tex1.w / (tex1.w + tex2.w); if(!first) { // first = true means its the first part of the wrapped texture ratio = 1 - ratio; } const adjBB = this._getAdjustedBB(bb, padding, first, ratio); this._applyTransformMatrix(matrix, adjBB, opts, ele); } else { // we don't have a texture, or we want to avoid creating a texture for simple shapes const bb = opts.getBoundingBox(ele); const adjBB = this._getAdjustedBB(bb, padding, true, 1); this._applyTransformMatrix(matrix, adjBB, opts, ele); } } _applyTransformMatrix(matrix, adjBB, opts, ele) { let x, y; mat3.identity(matrix); const theta = opts.getRotation ? opts.getRotation(ele) : 0; if(theta !== 0) { const { x:sx, y:sy } = opts.getRotationPoint(ele); mat3.translate(matrix, matrix, [sx, sy]); mat3.rotate(matrix, matrix, theta); const offset = opts.getRotationOffset(ele); x = offset.x + (adjBB.xOffset || 0); y = offset.y + (adjBB.yOffset || 0); } else { x = adjBB.x1; y = adjBB.y1; } mat3.translate(matrix, matrix, [x, y]); mat3.scale(matrix, matrix, [adjBB.w, adjBB.h]); } /** * Adjusts a node or label BB to accomodate padding and split for wrapped textures. * @param bb - the original bounding box * @param padding - the padding to add to the bounding box * @param first - whether this is the first part of a wrapped texture * @param ratio - the ratio of the texture width of part of the text to the entire texture */ _getAdjustedBB(bb, padding, first, ratio) { let { x1, y1, w, h, yOffset } = bb; if(padding) { x1 -= padding; y1 -= padding; w += 2 * padding; h += 2 * padding; } let xOffset = 0; const adjW = w * ratio; if(first && ratio < 1) { w = adjW; } else if(!first && ratio < 1) { xOffset = w - adjW; x1 += xOffset; w = adjW; } return { x1, y1, w, h, xOffset, yOffset }; } /** * Draw a solid opaque rectangle matching the element's Bounding Box. * Used by the PICKING mode to make the entire BB of a label clickable. */ drawPickingRectangle(ele, eleIndex, type) { const opts = this.atlasManager.getRenderTypeOpts(type); const instance = this.instanceCount; this.vertTypeBuffer.getView(instance)[0] = RECTANGLE; const indexView = this.indexBuffer.getView(instance); util.indexToVec4(eleIndex, indexView); const colorView = this.colorBuffer.getView(instance); util.toWebGLColor([0,0,0], 1, colorView); // opaque, so entire label BB is clickable const matrixView = this.transformBuffer.getMatrixView(instance); this.setTransformMatrix(ele, matrixView, opts); this.simpleCount++; this.instanceCount++; if(this.instanceCount >= this.maxInstances) { this.endBatch(); } } /** * Draw a node using either a texture or a "simple shape". */ drawNode(node, eleIndex, type) { const opts = this.simpleShapeOptions.get(type); if(!this._isVisible(node, opts)) { return; } const props = opts.shapeProps; // Check if we have to use a texture const vertType = this._getVertTypeForShape(node, props.shape); if(vertType === undefined || (opts.isSimple && !opts.isSimple(node))) { this.drawTexture(node, eleIndex, type); return; } // Render a "simple shape" using SDF (signed distance fields) const instance = this.instanceCount; this.vertTypeBuffer.getView(instance)[0] = vertType; if(vertType === ROUND_RECTANGLE || vertType === BOTTOM_ROUND_RECTANGLE) { // get corner radius const bb = opts.getBoundingBox(node); const radius = this._getCornerRadius(node, props.radius, bb); const radiusView = this.cornerRadiusBuffer.getView(instance); radiusView[0] = radius; // top-right radiusView[1] = radius; // bottom-right radiusView[2] = radius; // top-left radiusView[3] = radius; // bottom-left if(vertType === BOTTOM_ROUND_RECTANGLE) { radiusView[0] = 0; radiusView[2] = 0; } } const indexView = this.indexBuffer.getView(instance); util.indexToVec4(eleIndex, indexView); const color = node.pstyle(props.color).value; const opacity = node.pstyle(props.opacity).value; const colorView = this.colorBuffer.getView(instance); util.toWebGLColor(color, opacity, colorView); const lineWidthView = this.lineWidthBuffer.getView(instance); // reuse edge line width attribute for node border lineWidthView[0] = 0; lineWidthView[1] = 0; if(props.border) { const borderWidth = node.pstyle('border-width').value; if(borderWidth > 0) { const borderColor = node.pstyle('border-color').value; const borderOpacity = node.pstyle('border-opacity').value; const borderColorView = this.borderColorBuffer.getView(instance); util.toWebGLColor(borderColor, borderOpacity, borderColorView); // SDF distance is negative inside the shape and positive outside const borderPos = node.pstyle('border-position').value; if(borderPos === 'inside') { lineWidthView[0] = 0; lineWidthView[1] = -borderWidth; } else if(borderPos === 'outside') { lineWidthView[0] = borderWidth; lineWidthView[1] = 0; } else { // 'center' const halfWidth = borderWidth / 2; lineWidthView[0] = halfWidth; lineWidthView[1] = -halfWidth; } } } const matrixView = this.transformBuffer.getMatrixView(instance); this.setTransformMatrix(node, matrixView, opts); this.simpleCount++; this.instanceCount++; if(this.instanceCount >= this.maxInstances) { this.endBatch(); } } _getVertTypeForShape(node, shapeProp) { const shape = node.pstyle(shapeProp).value switch(shape) { case 'rectangle': return RECTANGLE; case 'ellipse': return ELLIPSE; case 'roundrectangle': case 'round-rectangle': return ROUND_RECTANGLE; case 'bottom-round-rectangle': return BOTTOM_ROUND_RECTANGLE; default: return undefined; } } _getCornerRadius(node, radiusProp, { w, h }) { // see CRp.drawRoundRectanglePath if(node.pstyle(radiusProp).value === 'auto') { return math.getRoundRectangleRadius(w, h); } else { const radius = node.pstyle(radiusProp).pfValue; const halfWidth = w / 2; const halfHeight = h / 2; return Math.min(radius, halfHeight, halfWidth); } } /** * Only supports drawing triangles at the moment. */ drawEdgeArrow(edge, eleIndex, prefix) { if(!edge.visible()) { return; } // Edge points and arrow angles etc are calculated by the base renderer and cached in the rscratch object. const rs = edge._private.rscratch; let x, y, angle; if(prefix === 'source') { x = rs.arrowStartX; y = rs.arrowStartY; angle = rs.srcArrowAngle; } else { x = rs.arrowEndX; y = rs.arrowEndY; angle = rs.tgtArrowAngle; } // taken from CRp.drawArrowhead if(isNaN(x) || x == null || isNaN(y) || y == null || isNaN(angle) || angle == null) { return; } // check shape after the x/y check because pstyle() is a bit slow const arrowShape = edge.pstyle(prefix + '-arrow-shape').value; if(arrowShape === 'none') { return; } const color = edge.pstyle(prefix + '-arrow-color').value; const baseOpacity = edge.pstyle('opacity').value; const lineOpacity = edge.pstyle('line-opacity').value; const opacity = baseOpacity * lineOpacity; const lineWidth = edge.pstyle('width').pfValue; const scale = edge.pstyle('arrow-scale').value; const size = this.r.getArrowWidth(lineWidth, scale); const instance = this.instanceCount; const transform = this.transformBuffer.getMatrixView(instance); mat3.identity(transform); mat3.translate(transform, transform, [x, y]); mat3.scale(transform, transform, [size, size]); mat3.rotate(transform, transform, angle); this.vertTypeBuffer.getView(instance)[0] = EDGE_ARROW; const indexView = this.indexBuffer.getView(instance); util.indexToVec4(eleIndex, indexView); const colorView = this.colorBuffer.getView(instance); util.toWebGLColor(color, opacity, colorView); this.instanceCount++; if(this.instanceCount >= this.maxInstances) { this.endBatch(); } } /** * Draw straight-line or bezier curve edges. */ drawEdgeLine(edge, eleIndex) { if(!edge.visible()) { return; } const points = this._getEdgePoints(edge); if(!points) { return; } // line style const baseOpacity = edge.pstyle('opacity').value; const lineOpacity = edge.pstyle('line-opacity').value; const width = edge.pstyle('width').pfValue; const color = edge.pstyle('line-color').value; const opacity = baseOpacity * lineOpacity; if(points.length/2 + this.instanceCount > this.maxInstances) { this.endBatch(); } if(points.length == 4) { // straight line const instance = this.instanceCount; this.vertTypeBuffer.getView(instance)[0] = EDGE_STRAIGHT; const indexView = this.indexBuffer.getView(instance); util.indexToVec4(eleIndex, indexView); const colorView = this.colorBuffer.getView(instance); util.toWebGLColor(color, opacity, colorView); const lineWidthBuffer = this.lineWidthBuffer.getView(instance); lineWidthBuffer[0] = width; const sourceTargetView = this.pointAPointBBuffer.getView(instance); sourceTargetView[0] = points[0]; // source x sourceTargetView[1] = points[1]; // source y sourceTargetView[2] = points[2]; // target x sourceTargetView[3] = points[3]; // target y this.instanceCount++; if(this.instanceCount >= this.maxInstances) { this.endBatch(); } } else { // curved line for(let i = 0; i < points.length-2; i += 2) { const instance = this.instanceCount; this.vertTypeBuffer.getView(instance)[0] = EDGE_CURVE_SEGMENT; const indexView = this.indexBuffer.getView(instance); util.indexToVec4(eleIndex, indexView); const colorView = this.colorBuffer.getView(instance); util.toWebGLColor(color, opacity, colorView); const lineWidthBuffer = this.lineWidthBuffer.getView(instance); lineWidthBuffer[0] = width; let pAx = points[i-2], pAy = points[i-1]; let pBx = points[i ], pBy = points[i+1]; let pCx = points[i+2], pCy = points[i+3]; let pDx = points[i+4], pDy = points[i+5]; // make phantom points for the first and last segments // TODO adding 0.001 to avoid division by zero in the shader (I think), need a better solution if(i == 0) { pAx = 2*pBx - pCx + 0.001; pAy = 2*pBy - pCy + 0.001; } if(i == points.length-4) { pDx = 2*pCx - pBx + 0.001; pDy = 2*pCy - pBy + 0.001; } const pointABView = this.pointAPointBBuffer.getView(instance); pointABView[0] = pAx; pointABView[1] = pAy; pointABView[2] = pBx; pointABView[3] = pBy; const pointCDView = this.pointCPointDBuffer.getView(instance); pointCDView[0] = pCx; pointCDView[1] = pCy; pointCDView[2] = pDx; pointCDView[3] = pDy; this.instanceCount++; if(this.instanceCount >= this.maxInstances) { this.endBatch(); } } } } _getEdgePoints(edge) { const rs = edge._private.rscratch; // if bezier ctrl pts can not be calculated, then die if( rs.badLine || rs.allpts == null || isNaN(rs.allpts[0]) ){ // isNaN in case edge is impossible and browser bugs (e.g. safari) return; } const controlPoints = rs.allpts; if(controlPoints.length == 4) { return controlPoints; } const numSegments = this._getNumSegments(edge); return this._getCurveSegmentPoints(controlPoints, numSegments); } _getNumSegments(edge) { // TODO Need a heuristic that decides how many segments to use. Factors to consider: // - edge width/length // - edge curvature (the more the curvature, the more segments) // - zoom level (more segments when zoomed in) // - number of visible edges (more segments when there are fewer edges) // - performance (fewer segments when performance is a concern) // - user configurable option(s) // note: number of segments must be less than the max number of instances // note: segments don't need to be evenly spaced out, it might make sense to have shorter segments nearer to the control points const numSegments = 15; return Math.min(Math.max(numSegments, 5), this.maxInstances); } _getCurveSegmentPoints(controlPoints, segments) { if(controlPoints.length == 4) { return controlPoints; // straight line } const curvePoints = Array((segments + 1) * 2); for(let i = 0; i <= segments; i++) { // the first and last points are the same as the first and last control points if(i == 0) { curvePoints[0] = controlPoints[0]; curvePoints[1] = controlPoints[1]; } else if(i == segments) { curvePoints[i*2 ] = controlPoints[controlPoints.length-2]; curvePoints[i*2+1] = controlPoints[controlPoints.length-1]; } else { const t = i / segments; // segments have equal length, its not strictly necessary to do it this way // pass in curvePoints to set the values in the array directly this._setCurvePoint(controlPoints, t, curvePoints, i*2); } } return curvePoints; } _setCurvePoint(points, t, curvePoints, cpi) { if(points.length <= 2) { curvePoints[cpi ] = points[0]; curvePoints[cpi+1] = points[1]; } else { const newpoints = Array(points.length-2); for(let i = 0; i < newpoints.length; i+=2) { const x = (1-t) * points[i ] + t * points[i+2]; const y = (1-t) * points[i+1] + t * points[i+3]; newpoints[i ] = x; newpoints[i+1] = y; } return this._setCurvePoint(newpoints, t, curvePoints, cpi); } } endBatch() { const { gl, vao, vertexCount, instanceCount: count } = this; if(count === 0) return; const program = this.renderTarget.picking ? this.pickingProgram : this.program; gl.useProgram(program); gl.bindVertexArray(vao); // buffer the attribute data for(const buffer of this.buffers) { buffer.bufferSubData(count); } const atlases = this.batchManager.getAtlases(); // must buffer before activating texture units for(let i = 0; i < atlases.length; i++) { atlases[i].bufferIfNeeded(gl); } // Activate all the texture units that we need for(let i = 0; i < atlases.length; i++) { gl.activeTexture(gl.TEXTURE0 + i); gl.bindTexture(gl.TEXTURE_2D, atlases[i].texture); gl.uniform1i(program.uTextures[i], i); } // Set the uniforms gl.uniform1f(program.uZoom, util.getEffectiveZoom(this.r)); gl.uniformMatrix3fv(program.uPanZoomMatrix, false, this.panZoomMatrix); gl.uniform1i(program.uAtlasSize, this.batchManager.getAtlasSize()); // set background color, needed for edge arrow color blending const webglBgColor = util.toWebGLColor(this.bgColor, 1); gl.uniform4fv(program.uBGColor, webglBgColor); // draw! gl.drawArraysInstanced(gl.TRIANGLES, 0, vertexCount, count); gl.bindVertexArray(null); gl.bindTexture(gl.TEXTURE_2D, null); // TODO is this right when having multiple texture units? if(this.debug) { this.batchDebugInfo.push({ count, // instance count atlasCount: atlases.length }); } // start the next batch, even if not needed this.startBatch(); } getDebugInfo() { const atlasInfo = this.atlasManager.getDebugInfo(); const totalAtlases = atlasInfo.reduce((count, info) => count + info.atlasCount, 0); const batchInfo = this.batchDebugInfo; const totalInstances = batchInfo.reduce((count, info) => count + info.count, 0); return { atlasInfo, totalAtlases, wrappedCount: this.wrappedCount, simpleCount: this.simpleCount, batchCount: batchInfo.length, batchInfo, totalInstances }; } }