cytoscape
Version:
Graph theory (a.k.a. network) library for analysis and visualisation
732 lines (582 loc) • 23 kB
JavaScript
import * as util from './webgl-util.mjs';
import { mat3 } from 'gl-matrix';
import { RENDER_TARGET } from './defaults.mjs';
import { AtlasManager } from './atlas.mjs';
// Vertex types
const TEXTURE = 0;
const EDGE_STRAIGHT = 1;
const EDGE_CURVE_SEGMENT = 2;
const EDGE_ARROW = 3;
const RECTANGLE = 4;
export class ElementDrawingWebGL {
/**
* @param {WebGLRenderingContext} gl
*/
constructor(r, gl, opts) {
this.r = r;
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.program = this.createShaderProgram(RENDER_TARGET.SCREEN);
this.pickingProgram = this.createShaderProgram(RENDER_TARGET.PICKING);
this.vao = this.createVAO();
}
addAtlasCollection(groupName, opts) {
this.atlasManager.addAtlasCollection(groupName, opts);
}
addAtlasRenderType(typeName, opts) {
this.atlasManager.addRenderType(typeName, opts);
}
invalidate(eles, { type } = {}) {
const { atlasManager } = this;
if(type) {
return atlasManager.invalidate(eles, {
filterType: t => t === type,
forceRedraw: true
});
} else {
return atlasManager.invalidate(eles);
}
}
gc() {
this.atlasManager.gc();
}
createShaderProgram(renderTarget) {
const { gl } = this;
// compute texture coordinates in the shader, becase we are using instanced drawing
const vertexShaderSource = `#version 300 es
precision highp float;
uniform mat3 uPanZoomMatrix;
uniform int uAtlasSize;
// instanced
in vec2 aPosition;
in mat3 aTransform;
// what are we rendering?
in int aVertType;
// for picking
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 float aLineWidth;
in vec4 aColor;
out vec2 vTexCoord;
out vec4 vColor;
flat out int vAtlasId;
flat out vec4 vIndex;
flat out int vVertType;
void main(void) {
int vid = gl_VertexID;
vec2 position = aPosition;
if(aVertType == ${TEXTURE}) {
float texX = aTex.x;
float texY = aTex.y;
float texW = aTex.z;
float texH = aTex.w;
int vid = gl_VertexID;
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}) {
gl_Position = vec4(uPanZoomMatrix * aTransform * vec3(position, 1.0), 1.0);
vColor = aColor;
}
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;
vec2 xBasis = target - source;
vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x));
vec2 point = source + xBasis * position.x + yBasis * aLineWidth * 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 = pointA;
vec2 p1 = pointB;
vec2 p2 = pointC;
vec2 pos = position;
if(position.x == 1.0) {
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;
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;
vIndex = aIndex;
vVertType = aVertType;
}
`;
const idxs = this.atlasManager.getIndexArray();
const fragmentShaderSource = `#version 300 es
precision highp float;
// define texture unit for each node in the batch
${idxs.map(i => `uniform sampler2D uTexture${i};`).join('\n\t')}
uniform vec4 uBGColor;
in vec2 vTexCoord;
in vec4 vColor;
flat in int vAtlasId;
flat in vec4 vIndex;
flat in int vVertType;
out vec4 outColor;
void main(void) {
if(vVertType == ${TEXTURE}) {
${idxs.map(i => `if(vAtlasId == ${i}) outColor = texture(uTexture${i}, vTexCoord);`).join('\n\telse ')}
} else if(vVertType == ${EDGE_ARROW}) {
// blend arrow color with background (using premultiplied alpha)
outColor.rgb = vColor.rgb + (uBGColor.rgb * (1.0 - vColor.a));
outColor.a = 1.0; // make opaque, masks out line under arrow
} 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');
// uniforms
program.uPanZoomMatrix = gl.getUniformLocation(program, 'uPanZoomMatrix');
program.uAtlasSize = gl.getUniformLocation(program, 'uAtlasSize');
program.uBGColor = gl.getUniformLocation(program, 'uBGColor');
program.uTextures = [];
for(let i = 0; i < this.atlasManager.getMaxAtlasesPerBatch(); i++) {
program.uTextures.push(gl.getUniformLocation(program, `uTexture${i}`));
}
return program;
}
createVAO() {
const instanceGeometry = [
0, 0, 1, 0, 1, 1,
0, 0, 1, 1, 0, 1,
];
this.vertexCount = instanceGeometry.length / 2;
const n = this.maxInstances;
const { gl, program } = this;
const vao = gl.createVertexArray();
gl.bindVertexArray(vao);
util.createBufferStaticDraw(gl, 'vec2', program.aPosition, instanceGeometry);
// 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, 'float', program.aLineWidth);
this.colorBuffer = util.createBufferDynamicDraw(gl, n, 'vec4', program.aColor);
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; // TODO this should be in the AtlasManager
this.rectangleCount = 0;
this.startBatch();
}
startBatch() {
this.instanceCount = 0;
this.atlasManager.startBatch();
}
endFrame() {
this.endBatch();
}
getTempMatrix() {
return this.tempMatrix = this.tempMatrix || mat3.create();
}
drawTexture(ele, eleIndex, type) {
const { atlasManager } = this;
if(!ele.visible()) {
return;
}
if(!atlasManager.getRenderTypeOpts(type).isVisible(ele)) {
return;
}
if(!atlasManager.canAddToCurrentBatch(ele, type)) {
this.endBatch(); // draws then starts a new batch
}
if(this.instanceCount + 1 >= this.maxInstances) {
this.endBatch(); // make sure there's space for at least two instances, wrapped textures need two instances
}
const instance = this.instanceCount;
this.vertTypeBuffer.getView(instance)[0] = TEXTURE;
const indexView = this.indexBuffer.getView(instance);
util.indexToVec4(eleIndex, indexView);
const atlasInfo = atlasManager.getAtlasInfo(ele, type);
const { index, tex1, tex2 } = atlasInfo;
if(tex2.w > 0)
this.wrappedCount++;
let first = true;
for(const tex of [tex1, tex2]) {
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] = index;
// 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);
atlasManager.setTransformMatrix(ele, matrixView, type, atlasInfo, first);
this.instanceCount++;
}
first = false;
}
if(this.instanceCount >= this.maxInstances) {
this.endBatch();
}
}
drawSimpleRectangle(ele, eleIndex, type) {
if(!ele.visible()) {
return;
}
const { atlasManager } = this;
const instance = this.instanceCount;
this.vertTypeBuffer.getView(instance)[0] = RECTANGLE;
const indexView = this.indexBuffer.getView(instance);
util.indexToVec4(eleIndex, indexView);
const color = ele.pstyle('background-color').value;
const opacity = ele.pstyle('background-opacity').value;
const colorView = this.colorBuffer.getView(instance);
util.toWebGLColor(color, opacity, colorView);
const matrixView = this.transformBuffer.getMatrixView(instance);
atlasManager.setTransformMatrix(ele, matrixView, type);
this.rectangleCount++;
this.instanceCount++;
if(this.instanceCount >= this.maxInstances) {
this.endBatch();
}
}
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();
}
}
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 should 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.atlasManager.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.uniformMatrix3fv(program.uPanZoomMatrix, false, this.panZoomMatrix);
gl.uniform1i(program.uAtlasSize, this.atlasManager.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,
rectangleCount: this.rectangleCount,
batchCount: batchInfo.length,
batchInfo,
totalInstances
};
}
}