diff --git a/.gitignore b/.gitignore index 2eba078..4b7568e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ # IDEs and editors .idea/ +.vscode/ # Optional npm cache directory .npm diff --git a/package.json b/package.json index bfb4610..006c5e5 100644 --- a/package.json +++ b/package.json @@ -109,4 +109,4 @@ "main" ] } -} \ No newline at end of file +} diff --git a/src/glsl.d.ts b/src/glsl.d.ts new file mode 100644 index 0000000..531b1d0 --- /dev/null +++ b/src/glsl.d.ts @@ -0,0 +1,14 @@ +declare module '*.glsl' { + const value: string; + export default value; +} + +declare module '*.vert' { + const value: string; + export default value; +} + +declare module '*.frag' { + const value: string; + export default value; +} diff --git a/src/renderer/webgl/shaders/edge/edge.frag b/src/renderer/webgl/shaders/edge/edge.frag new file mode 100644 index 0000000..f2efc8f --- /dev/null +++ b/src/renderer/webgl/shaders/edge/edge.frag @@ -0,0 +1,151 @@ +#version 300 es + +precision highp float; + +in vec2 vWorldPos; +in vec2 vStart; +in vec2 vEnd; +in vec2 vControl; +in float vHalfWidth; +in float vLoopbackRadius; +in float vArrowSize; +in vec2 vArrowTip; +in vec2 vArrowDir; +in vec4 vColor; +in vec4 vShadowColor; +in float vShadowSize; +in vec2 vShadowOffset; +flat in int vEdgeType; + +uniform bool uSimpleMode; + +out vec4 fragColor; + +float sdSegment(vec2 p, vec2 a, vec2 b) { + vec2 pa = p - a; + vec2 ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + return length(pa - ba * h); +} + +float sdBezier(vec2 pos, vec2 A, vec2 B, vec2 C) { + vec2 a = B - A; + vec2 b = A - 2.0 * B + C; + vec2 c = a * 2.0; + vec2 d = A - pos; + + float kk = 1.0 / max(dot(b, b), 0.0001); + float kx = kk * dot(a, b); + float ky = kk * (2.0 * dot(a, a) + dot(d, b)) / 3.0; + float kz = kk * dot(d, a); + + float p = ky - kx * kx; + float q = kx * (2.0 * kx * kx - 3.0 * ky) + kz; + float p3 = p * p * p; + float q2 = q * q; + float h = q2 + 4.0 * p3; + + float res; + if (h >= 0.0) { + h = sqrt(h); + vec2 x = (vec2(h, -h) - q) / 2.0; + vec2 uv = sign(x) * pow(abs(x), vec2(1.0 / 3.0)); + float t = clamp(uv.x + uv.y - kx, 0.0, 1.0); + vec2 qo = d + (c + b * t) * t; + res = dot(qo, qo); + } else { + float z = sqrt(-p); + float v = acos(q / (p * z * 2.0)) / 3.0; + float m = cos(v); + float n = sin(v) * 1.732050808; + vec3 t = clamp(vec3(m + m, -n - m, n - m) * z - kx, 0.0, 1.0); + vec2 qx = d + (c + b * t.x) * t.x; + float dx = dot(qx, qx); + vec2 qy = d + (c + b * t.y) * t.y; + float dy = dot(qy, qy); + res = min(dx, dy); + } + + return sqrt(res); +} + +float sdArrow(vec2 p, vec2 tip, vec2 dir, float size) { + if (size <= 0.0) return 1e6; + + vec2 perp = vec2(-dir.y, dir.x); + vec2 rel = p - tip; + float along = dot(rel, -dir); + float across = dot(rel, perp); + + if (along < 0.0) return length(rel); + if (along > size) { + float hw = size * 0.4; + float closest = clamp(across, -hw, hw); + vec2 pt = tip - dir * size + perp * closest; + return length(p - pt); + } + + float halfW = (along / size) * size * 0.4; + float d = abs(across) - halfW; + return d; +} + +void main() { + if (uSimpleMode && vEdgeType == 0) { + fragColor = vColor; + return; + } + + // Edge SDF: type-based dispatch (always needed). + float dist; + if (vEdgeType == 0) { + dist = sdSegment(vWorldPos, vStart, vEnd); + } else if (vEdgeType == 1) { + dist = sdBezier(vWorldPos, vStart, vControl, vEnd); + } else { + dist = abs(length(vWorldPos - vControl) - vLoopbackRadius); + } + + float edgeSdf = dist - vHalfWidth; + float combinedSdf = edgeSdf; + + if (vArrowSize > 0.0) { + float arrowDist = sdArrow(vWorldPos, vArrowTip, vArrowDir, vArrowSize); + combinedSdf = min(edgeSdf, arrowDist); + } + + float shadowAlpha = 0.0; + if (vShadowSize > 0.0) { + vec2 shadowPos = vWorldPos - vShadowOffset; + float shadowDist; + if (vEdgeType == 0) { + shadowDist = sdSegment(shadowPos, vStart, vEnd); + } else if (vEdgeType == 1) { + shadowDist = sdBezier(shadowPos, vStart, vControl, vEnd); + } else { + shadowDist = abs(length(shadowPos - vControl) - vLoopbackRadius); + } + float shadowArrowDist = vArrowSize > 0.0 + ? sdArrow(shadowPos, vArrowTip, vArrowDir, vArrowSize) + : 1.0e6; + float shadowCombined = min(shadowDist - vHalfWidth, shadowArrowDist); + float t = max(shadowCombined, 0.0) / vShadowSize; + shadowAlpha = exp(-t * t * 1.5) * 0.5 * vShadowColor.a; + } + + float aa = fwidth(combinedSdf); + float edgeAlpha = 1.0 - smoothstep(-aa, aa, combinedSdf); + vec4 edgeColor = vColor; + edgeColor.a *= edgeAlpha; + + float finalAlpha = edgeColor.a + shadowAlpha * (1.0 - edgeColor.a); + + if (finalAlpha < 0.001) discard; + + if (shadowAlpha > 0.0) { + vec3 finalRGB = (edgeColor.rgb * edgeColor.a + vShadowColor.rgb * shadowAlpha * (1.0 - edgeColor.a)) / finalAlpha; + fragColor = vec4(finalRGB, finalAlpha); + } else { + fragColor = edgeColor; + } +} diff --git a/src/renderer/webgl/shaders/edge/edge.vert b/src/renderer/webgl/shaders/edge/edge.vert new file mode 100644 index 0000000..8a881e4 --- /dev/null +++ b/src/renderer/webgl/shaders/edge/edge.vert @@ -0,0 +1,94 @@ +#version 300 es + +precision highp float; + +in vec2 aQuadPosition; + +in vec2 aStart; +in vec2 aEnd; +in vec2 aControl; +in float aWidth; +in float aEdgeType; +in float aLoopbackRadius; +in float aArrowSize; +in vec2 aArrowTip; +in vec2 aArrowDir; +in vec4 aColor; +in vec4 aShadowColor; +in float aShadowSize; +in float aShadowOffsetX; +in float aShadowOffsetY; + +uniform vec2 uResolution; +uniform vec2 uTranslation; +uniform float uScale; +uniform vec2 uOriginOffset; + +out vec2 vWorldPos; +out vec2 vStart; +out vec2 vEnd; +out vec2 vControl; +out float vHalfWidth; +out float vLoopbackRadius; +out float vArrowSize; +out vec2 vArrowTip; +out vec2 vArrowDir; +out vec4 vColor; +out vec4 vShadowColor; +out float vShadowSize; +out vec2 vShadowOffset; +flat out int vEdgeType; + +void main() { + vEdgeType = int(aEdgeType + 0.5); + vStart = aStart; + vEnd = aEnd; + vControl = aControl; + float effectiveWidth = max(aWidth, 1.0 / uScale); + vHalfWidth = effectiveWidth * 0.5; + vLoopbackRadius = aLoopbackRadius; + vArrowSize = aArrowSize; + vArrowTip = aArrowTip; + vArrowDir = aArrowDir; + vColor = aColor; + vShadowColor = aShadowColor; + vShadowSize = aShadowSize; + vShadowOffset = vec2(aShadowOffsetX, aShadowOffsetY); + + float pad = vHalfWidth + aShadowSize + abs(aShadowOffsetX) + abs(aShadowOffsetY); + + vec2 worldPos; + + if (vEdgeType == 0) { + vec2 dir = aEnd - aStart; + float len = length(dir); + vec2 unitDir = dir / max(len, 0.0001); + vec2 perp = vec2(-unitDir.y, unitDir.x); + float totalHalf = pad + aArrowSize; + vec2 midpoint = (aStart + aEnd) * 0.5; + worldPos = midpoint + + unitDir * (len * 0.5 + totalHalf) * aQuadPosition.x + + perp * totalHalf * aQuadPosition.y; + } else if (vEdgeType == 1) { + float margin = pad + aArrowSize; + vec2 bboxMin = min(min(aStart, aEnd), aControl) - margin; + vec2 bboxMax = max(max(aStart, aEnd), aControl) + margin; + vec2 center = (bboxMin + bboxMax) * 0.5; + vec2 halfSize = (bboxMax - bboxMin) * 0.5; + worldPos = center + aQuadPosition * halfSize; + } else { + float margin = pad + aArrowSize; + vec2 ctr = aControl; + float r = aLoopbackRadius; + vec2 bboxMin = min(ctr - (r + margin), aStart - margin); + vec2 bboxMax = max(ctr + (r + margin), aStart + margin); + vec2 center = (bboxMin + bboxMax) * 0.5; + vec2 halfSize = (bboxMax - bboxMin) * 0.5; + worldPos = center + aQuadPosition * halfSize; + } + + vWorldPos = worldPos; + vec2 screenPos = (worldPos + uOriginOffset) * uScale + uTranslation; + vec2 clip = (screenPos / uResolution) * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); +} diff --git a/src/renderer/webgl/shaders/label/label.frag b/src/renderer/webgl/shaders/label/label.frag new file mode 100644 index 0000000..707d3cf --- /dev/null +++ b/src/renderer/webgl/shaders/label/label.frag @@ -0,0 +1,15 @@ +#version 300 es + +precision highp float; + +uniform sampler2D uAtlas; + +in vec2 vAtlasUV; + +out vec4 fragColor; + +void main() { + vec4 texel = texture(uAtlas, vAtlasUV); + if (texel.a < 0.01) discard; + fragColor = texel; +} diff --git a/src/renderer/webgl/shaders/label/label.vert b/src/renderer/webgl/shaders/label/label.vert new file mode 100644 index 0000000..c85c0a0 --- /dev/null +++ b/src/renderer/webgl/shaders/label/label.vert @@ -0,0 +1,25 @@ +#version 300 es + +in vec2 aQuadPosition; + +in vec2 aLabelCenter; +in vec2 aLabelSize; +in vec2 aLabelUV0; +in vec2 aLabelUV1; + +uniform vec2 uResolution; +uniform vec2 uTranslation; +uniform float uScale; +uniform vec2 uOriginOffset; + +out vec2 vAtlasUV; + +void main() { + vec2 worldPos = aLabelCenter + aQuadPosition * aLabelSize; + vec2 screenPos = (worldPos + uOriginOffset) * uScale + uTranslation; + vec2 clip = (screenPos / uResolution) * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + + vec2 uv01 = aQuadPosition * 0.5 + 0.5; + vAtlasUV = mix(aLabelUV0, aLabelUV1, uv01); +} diff --git a/src/renderer/webgl/shaders/node/node.frag b/src/renderer/webgl/shaders/node/node.frag new file mode 100644 index 0000000..edce819 --- /dev/null +++ b/src/renderer/webgl/shaders/node/node.frag @@ -0,0 +1,162 @@ +#version 300 es + +precision highp float; + +in vec2 vUV; +in vec4 vColor; +in vec4 vBorderColor; +in float vBorderThreshold; +in vec4 vShadowColor; +in float vNodeRadius; +in vec2 vShadowOffset; +in float vShadowBlur; +flat in int vShapeType; +in vec2 vImageUV0; +in vec2 vImageUV1; +in float vImageAspect; + +uniform sampler2D uImageAtlas; + +out vec4 fragColor; + +const int SHAPE_CIRCLE = 0; +const int SHAPE_DOT = 1; +const int SHAPE_SQUARE = 2; +const int SHAPE_DIAMOND = 3; +const int SHAPE_TRIANGLE = 4; +const int SHAPE_TRIANGLE_DOWN = 5; +const int SHAPE_STAR = 6; +const int SHAPE_HEXAGON = 7; + +float sdCircle(vec2 p, float r) { + return length(p) - r; +} + +float sdSquare(vec2 p, float r) { + vec2 d = abs(p) - vec2(r); + return max(d.x, d.y); +} + +float sdDiamond(vec2 p, float r) { + return (abs(p.x) + abs(p.y)) - r; +} + +float sdTriangleDown(vec2 p, float r) { + float sr = r * 1.15; + vec2 q = vec2(p.x, p.y - 0.275 * sr); + + float k = sqrt(3.0); + q.x = abs(q.x) - sr; + q.y = q.y + sr / k; + if (q.x + k * q.y > 0.0) { + q = vec2(q.x - k * q.y, -k * q.x - q.y) / 2.0; + } + q.x -= clamp(q.x, -2.0 * sr, 0.0); + return -length(q) * sign(q.y); +} + +float sdTriangleUp(vec2 p, float r) { + return sdTriangleDown(vec2(p.x, -p.y), r); +} + +float sdStar(vec2 p, float r) { + float sr = r * 0.82; + vec2 q = vec2(p.x, p.y - 0.1 * sr); + + float outerR = sr * 1.3; + float innerR = sr * 0.5; + + float angle = atan(q.x, -q.y); + float sector = 6.2831853 / 5.0; + float a = mod(angle + sector * 0.5, sector) - sector * 0.5; + + float cosA = cos(a); + float sinA = abs(sin(a)); + + float halfSector = sector * 0.5; + vec2 outerPt = vec2(outerR, 0.0); + vec2 innerPt = vec2(innerR * cos(halfSector), innerR * sin(halfSector)); + + vec2 sp = vec2(cosA, sinA) * length(q); + + vec2 edge = innerPt - outerPt; + vec2 toP = sp - outerPt; + float t = clamp(dot(toP, edge) / dot(edge, edge), 0.0, 1.0); + float dist = length(toP - edge * t); + + float cross2d = edge.x * toP.y - edge.y * toP.x; + return cross2d > 0.0 ? -dist : dist; +} + +float sdHexagon(vec2 p, float r) { + vec2 q = abs(p); + float k = sqrt(3.0); + float d = max(q.x, (q.x * 0.5 + q.y * (k * 0.5))); + return d - r; +} + +float shapeSDF(vec2 p, float r, int shapeType) { + if (shapeType == SHAPE_SQUARE) return sdSquare(p, r); + if (shapeType == SHAPE_DIAMOND) return sdDiamond(p, r); + if (shapeType == SHAPE_TRIANGLE) return sdTriangleUp(p, r); + if (shapeType == SHAPE_TRIANGLE_DOWN) return sdTriangleDown(p, r); + if (shapeType == SHAPE_STAR) return sdStar(p, r); + if (shapeType == SHAPE_HEXAGON) return sdHexagon(p, r); + + return sdCircle(p, r); +} + +void main() { + // Body SDF - always needed. + float dist = shapeSDF(vUV, vNodeRadius, vShapeType); + + float aa = 0.02 * vNodeRadius; + float nodeAlpha = 1.0 - smoothstep(-aa, 0.0, dist); + + // Shadow SDF - skip entirely when no shadow. Avoids a second full shapeSDF() call + // (which is a cascade of ifs) and the exp() per fragment. + float shadowAlpha = 0.0; + if (vShadowBlur > 0.0) { + float shadowDist = shapeSDF(vUV - vShadowOffset, vNodeRadius, vShapeType); + float t = max(shadowDist, 0.0) / vShadowBlur; + shadowAlpha = exp(-t * t * 1.5) * 0.5 * vShadowColor.a; + } + + vec4 fillColor = vColor; + if (vImageAspect > 0.0 && dist < 0.0) { + vec2 uv01 = (vUV / vNodeRadius) * 0.5 + 0.5; + if (vImageAspect > 1.0) { + uv01.x = (uv01.x - 0.5) / vImageAspect + 0.5; + } else { + uv01.y = (uv01.y - 0.5) * vImageAspect + 0.5; + } + if (uv01.x >= 0.0 && uv01.x <= 1.0 && uv01.y >= 0.0 && uv01.y <= 1.0) { + vec2 atlasUV = mix(vImageUV0, vImageUV1, uv01); + vec4 imgTexel = texture(uImageAtlas, atlasUV); + fillColor = mix(fillColor, vec4(imgTexel.rgb, 1.0), imgTexel.a); + } + } + + vec4 nodeColor; + if (vBorderThreshold < vNodeRadius) { + float borderDist = shapeSDF(vUV, vBorderThreshold, vShapeType); + float borderMix = smoothstep(-aa, aa, borderDist); + nodeColor = mix(fillColor, vBorderColor, borderMix); + } else { + nodeColor = fillColor; + } + nodeColor.a *= nodeAlpha; + + float finalAlpha = nodeColor.a + shadowAlpha * (1.0 - nodeColor.a); + + if (finalAlpha < 0.001) { + discard; + } + + if (shadowAlpha > 0.0) { + vec3 finalRGB = (nodeColor.rgb * nodeColor.a + vShadowColor.rgb * shadowAlpha * (1.0 - nodeColor.a)) / finalAlpha; + fragColor = vec4(finalRGB, finalAlpha); + } else { + fragColor = nodeColor; + } +} diff --git a/src/renderer/webgl/shaders/node/node.vert b/src/renderer/webgl/shaders/node/node.vert new file mode 100644 index 0000000..96fb121 --- /dev/null +++ b/src/renderer/webgl/shaders/node/node.vert @@ -0,0 +1,64 @@ +#version 300 es + +precision highp float; + +in vec2 aQuadPosition; + +in vec2 aCenter; +in float aRadius; +in vec4 aColor; +in vec4 aBorderColor; +in float aBorderWidth; +in vec4 aShadowColor; +in float aShadowSize; +in float aShadowOffsetX; +in float aShadowOffsetY; +in float aShapeType; +in vec2 aImageUV0; +in vec2 aImageUV1; +in float aImageAspect; + +uniform vec2 uResolution; +uniform vec2 uTranslation; +uniform float uScale; +uniform vec2 uOriginOffset; + +out vec2 vUV; +out vec4 vColor; +out vec4 vBorderColor; +out float vBorderThreshold; +out vec4 vShadowColor; +out float vNodeRadius; +out vec2 vShadowOffset; +out float vShadowBlur; +flat out int vShapeType; +out vec2 vImageUV0; +out vec2 vImageUV1; +out float vImageAspect; + +void main() { + vShapeType = int(aShapeType + 0.5); + vColor = aColor; + vBorderColor = aBorderColor; + vShadowColor = aShadowColor; + vImageUV0 = aImageUV0; + vImageUV1 = aImageUV1; + vImageAspect = aImageAspect; + + float totalRadius = aRadius + aShadowSize + abs(aShadowOffsetX) + abs(aShadowOffsetY); + + vUV = aQuadPosition; + vNodeRadius = aRadius / totalRadius; + + vBorderThreshold = vNodeRadius * (1.0 - aBorderWidth / aRadius); + + vShadowOffset = vec2(aShadowOffsetX, aShadowOffsetY) / totalRadius; + + vShadowBlur = aShadowSize / totalRadius; + + vec2 worldPos = aCenter + aQuadPosition * totalRadius; + vec2 screenPos = (worldPos + uOriginOffset) * uScale + uTranslation; + + vec2 clip = (screenPos / uResolution) * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); +} diff --git a/src/renderer/webgl/utils/color.utils.ts b/src/renderer/webgl/utils/color.utils.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/renderer/webgl/utils/image-atlas.ts b/src/renderer/webgl/utils/image-atlas.ts new file mode 100644 index 0000000..51efcf4 --- /dev/null +++ b/src/renderer/webgl/utils/image-atlas.ts @@ -0,0 +1,180 @@ +const ATLAS_WIDTH = 2048; +const ATLAS_HEIGHT = 2048; +const MAX_CELL_SIZE = 128; +const PADDING = 2; + +export interface ImageAtlasEntry { + u0: number; + v0: number; + u1: number; + v1: number; + aspect: number; +} + +interface Shelf { + y: number; + height: number; + x: number; +} + +interface PendingImage { + image: HTMLImageElement; + loaded: boolean; +} + +export class ImageAtlas { + private _gl: WebGL2RenderingContext; + private _canvas: HTMLCanvasElement; + private _ctx: CanvasRenderingContext2D; + private _texture: WebGLTexture | null = null; + private _cache = new Map(); + private _pending = new Map(); + private _shelves: Shelf[] = []; + private _dirty = false; + private _textureAllocated = false; + + constructor(gl: WebGL2RenderingContext) { + this._gl = gl; + this._canvas = document.createElement('canvas'); + this._canvas.width = ATLAS_WIDTH; + this._canvas.height = ATLAS_HEIGHT; + this._ctx = this._canvas.getContext('2d', { willReadFrequently: false })!; + this._texture = gl.createTexture(); + + gl.bindTexture(gl.TEXTURE_2D, this._texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.bindTexture(gl.TEXTURE_2D, null); + } + + getOrCreate(url: string): ImageAtlasEntry | null { + const cached = this._cache.get(url); + if (cached) { + return cached; + } + + const pending = this._pending.get(url); + if (pending) { + if (!pending.loaded) { + return null; + } + return this._packImage(url, pending.image); + } + + const image = new Image(); + image.crossOrigin = 'anonymous'; + + const record: PendingImage = { image, loaded: false }; + this._pending.set(url, record); + + image.onload = () => { + record.loaded = true; + }; + image.onerror = () => { + this._pending.delete(url); + }; + image.src = url; + + return null; + } + + bind(unit: number): void { + const gl = this._gl; + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, this._texture); + } + + uploadIfDirty(): void { + if (!this._dirty) { + return; + } + + const gl = this._gl; + gl.bindTexture(gl.TEXTURE_2D, this._texture); + + if (!this._textureAllocated) { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, ATLAS_WIDTH, ATLAS_HEIGHT, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + this._textureAllocated = true; + } + + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, this._canvas); + + gl.bindTexture(gl.TEXTURE_2D, null); + this._dirty = false; + } + + clear(): void { + this._cache.clear(); + this._pending.clear(); + this._shelves = []; + this._dirty = false; + this._ctx.clearRect(0, 0, ATLAS_WIDTH, ATLAS_HEIGHT); + } + + private _packImage(url: string, image: HTMLImageElement): ImageAtlasEntry | null { + if (!image.naturalWidth || !image.naturalHeight) { + return null; + } + + const aspect = image.naturalWidth / image.naturalHeight; + + let drawW: number; + let drawH: number; + if (image.naturalWidth >= image.naturalHeight) { + drawW = Math.min(image.naturalWidth, MAX_CELL_SIZE); + drawH = Math.round(drawW / aspect); + } else { + drawH = Math.min(image.naturalHeight, MAX_CELL_SIZE); + drawW = Math.round(drawH * aspect); + } + + const cellW = drawW + PADDING * 2; + const cellH = drawH + PADDING * 2; + + const slot = this._allocate(cellW, cellH); + if (!slot) { + return null; + } + + this._ctx.drawImage(image, slot.x + PADDING, slot.y + PADDING, drawW, drawH); + + const entry: ImageAtlasEntry = { + u0: (slot.x + PADDING) / ATLAS_WIDTH, + v0: (slot.y + PADDING) / ATLAS_HEIGHT, + u1: (slot.x + PADDING + drawW) / ATLAS_WIDTH, + v1: (slot.y + PADDING + drawH) / ATLAS_HEIGHT, + aspect, + }; + + this._cache.set(url, entry); + this._pending.delete(url); + this._dirty = true; + return entry; + } + + private _allocate(w: number, h: number): { x: number; y: number } | null { + for (let i = 0; i < this._shelves.length; i++) { + const shelf = this._shelves[i]; + if (shelf.x + w <= ATLAS_WIDTH && h <= shelf.height) { + const pos = { x: shelf.x, y: shelf.y }; + shelf.x += w; + return pos; + } + } + + const shelfY = + this._shelves.length === 0 + ? 0 + : this._shelves[this._shelves.length - 1].y + this._shelves[this._shelves.length - 1].height; + + if (shelfY + h > ATLAS_HEIGHT) { + return null; + } + + const newShelf: Shelf = { y: shelfY, height: h, x: w }; + this._shelves.push(newShelf); + return { x: 0, y: shelfY }; + } +} diff --git a/src/renderer/webgl/utils/label-cache.ts b/src/renderer/webgl/utils/label-cache.ts new file mode 100644 index 0000000..ed90fdf --- /dev/null +++ b/src/renderer/webgl/utils/label-cache.ts @@ -0,0 +1,181 @@ +const ATLAS_WIDTH = 2048; +const ATLAS_HEIGHT = 2048; +const RASTER_FONT_PX = 48; +const FONT_LINE_SPACING = 1.2; +const FONT_BACKGROUND_MARGIN = 0.12; +const PADDING = 2; + +export interface LabelAtlasEntry { + u0: number; + v0: number; + u1: number; + v1: number; + pxWidth: number; + pxHeight: number; +} + +interface Shelf { + y: number; + height: number; + x: number; +} + +export class LabelCache { + private _gl: WebGL2RenderingContext; + private _canvas: HTMLCanvasElement; + private _ctx: CanvasRenderingContext2D; + private _texture: WebGLTexture | null = null; + private _cache = new Map(); + private _shelves: Shelf[] = []; + private _dirty = false; + private _textureAllocated = false; + + constructor(gl: WebGL2RenderingContext) { + this._gl = gl; + this._canvas = document.createElement('canvas'); + this._canvas.width = ATLAS_WIDTH; + this._canvas.height = ATLAS_HEIGHT; + this._ctx = this._canvas.getContext('2d', { willReadFrequently: false })!; + this._texture = gl.createTexture(); + + gl.bindTexture(gl.TEXTURE_2D, this._texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.bindTexture(gl.TEXTURE_2D, null); + } + + getOrCreate( + text: string, + fontSize: number, + fontFamily: string, + fontColor: string, + bgColor: string | null, + ): LabelAtlasEntry | null { + const key = `${text}|${fontSize}|${fontFamily}|${fontColor}|${bgColor ?? ''}`; + const cached = this._cache.get(key); + if (cached) { + return cached; + } + + const lines = text.split('\n').map((l) => l.trim()); + if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) { + return null; + } + + const ctx = this._ctx; + const fontStr = `${RASTER_FONT_PX}px ${fontFamily}`; + ctx.font = fontStr; + + let maxLineWidth = 0; + for (let i = 0; i < lines.length; i++) { + const w = ctx.measureText(lines[i]).width; + if (w > maxLineWidth) { + maxLineWidth = w; + } + } + + const margin = RASTER_FONT_PX * FONT_BACKGROUND_MARGIN; + const lineHeight = RASTER_FONT_PX * FONT_LINE_SPACING; + const textBlockHeight = RASTER_FONT_PX + (lines.length - 1) * lineHeight; + const pxWidth = Math.ceil(maxLineWidth + margin * 2) + PADDING * 2; + const pxHeight = Math.ceil(textBlockHeight + margin * 2) + PADDING * 2; + + const slot = this._allocate(pxWidth, pxHeight); + if (!slot) { + return null; + } + + const ox = slot.x + PADDING; + const oy = slot.y + PADDING; + + if (bgColor) { + ctx.fillStyle = bgColor; + ctx.fillRect(ox, oy, pxWidth - PADDING * 2, pxHeight - PADDING * 2); + } + + ctx.font = fontStr; + ctx.fillStyle = fontColor; + ctx.textBaseline = 'top'; + ctx.textAlign = 'center'; + + const centerX = ox + (pxWidth - PADDING * 2) / 2; + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i], centerX, oy + margin + i * lineHeight); + } + + const entry: LabelAtlasEntry = { + u0: slot.x / ATLAS_WIDTH, + v0: slot.y / ATLAS_HEIGHT, + u1: (slot.x + pxWidth) / ATLAS_WIDTH, + v1: (slot.y + pxHeight) / ATLAS_HEIGHT, + pxWidth, + pxHeight, + }; + + this._cache.set(key, entry); + this._dirty = true; + return entry; + } + + bind(unit: number): void { + const gl = this._gl; + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, this._texture); + } + + uploadIfDirty(): void { + if (!this._dirty) { + return; + } + + const gl = this._gl; + gl.bindTexture(gl.TEXTURE_2D, this._texture); + + if (!this._textureAllocated) { + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, ATLAS_WIDTH, ATLAS_HEIGHT, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + this._textureAllocated = true; + } + + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, this._canvas); + + gl.bindTexture(gl.TEXTURE_2D, null); + this._dirty = false; + } + + clear(): void { + this._cache.clear(); + this._shelves = []; + this._dirty = false; + this._ctx.clearRect(0, 0, ATLAS_WIDTH, ATLAS_HEIGHT); + } + + get rasterFontPx(): number { + return RASTER_FONT_PX; + } + + private _allocate(w: number, h: number): { x: number; y: number } | null { + for (let i = 0; i < this._shelves.length; i++) { + const shelf = this._shelves[i]; + if (shelf.x + w <= ATLAS_WIDTH && h <= shelf.height) { + const pos = { x: shelf.x, y: shelf.y }; + shelf.x += w; + return pos; + } + } + + const shelfY = + this._shelves.length === 0 + ? 0 + : this._shelves[this._shelves.length - 1].y + this._shelves[this._shelves.length - 1].height; + + if (shelfY + h > ATLAS_HEIGHT) { + return null; + } + + const newShelf: Shelf = { y: shelfY, height: h, x: w }; + this._shelves.push(newShelf); + return { x: 0, y: shelfY }; + } +} diff --git a/src/renderer/webgl/webgl-renderer.ts b/src/renderer/webgl/webgl-renderer.ts index 52ba3a1..73362a1 100644 --- a/src/renderer/webgl/webgl-renderer.ts +++ b/src/renderer/webgl/webgl-renderer.ts @@ -1,8 +1,8 @@ import { zoomIdentity, ZoomTransform } from 'd3-zoom'; -import { INodeBase } from '../../models/node'; -import { IEdgeBase } from '../../models/edge'; +import { INode, INodeBase } from '../../models/node'; +import { IEdge, IEdgeBase, EdgeCurved, EdgeLoopback } from '../../models/edge'; import { IGraph } from '../../models/graph'; -import { IPosition, IRectangle } from '../../common'; +import { Color, IPosition, IRectangle } from '../../common'; import { Emitter } from '../../utils/emitter.utils'; import { DEFAULT_RENDERER_HEIGHT, @@ -11,35 +11,123 @@ import { IRenderer, RendererEvents as RE, IRendererSettings, + RenderEventType, } from '../shared'; import { copyObject } from '../../utils/object.utils'; import { appendCanvas, setupContainer } from '../../utils/html.utils'; import { OrbError } from '../../exceptions'; +import { createProgram } from '../../utils/program.utils'; +import { NodeShapeType } from '../../models/node'; +import nodeVertexSource from './shaders/node/node.vert'; +import nodeFragmentSource from './shaders/node/node.frag'; +import edgeVertexSource from './shaders/edge/edge.vert'; +import edgeFragmentSource from './shaders/edge/edge.frag'; +import labelVertexSource from './shaders/label/label.vert'; +import labelFragmentSource from './shaders/label/label.frag'; +import { LabelCache } from './utils/label-cache'; +import { ImageAtlas } from './utils/image-atlas'; + +const EDGE_TYPE_STRAIGHT = 0; +const EDGE_TYPE_CURVED = 1; +const EDGE_TYPE_LOOPBACK = 2; + +// Shared default color tuples — same array reused across all cache misses. +// Without this, every miss in the render loop allocated a fresh 4-element array +// (~hundreds of thousands of allocations per frame at scale). Must not be mutated. +const TRANSPARENT_RGBA: [number, number, number, number] = [0, 0, 0, 0]; +const EDGE_DEFAULT_RGBA: [number, number, number, number] = [0.6, 0.6, 0.6, 1]; +const NODE_DEFAULT_RGBA: [number, number, number, number] = [1, 0, 0, 1]; + +const SHAPE_TYPE_MAP: Record = { + [NodeShapeType.CIRCLE]: 0, + [NodeShapeType.DOT]: 1, + [NodeShapeType.SQUARE]: 2, + [NodeShapeType.DIAMOND]: 3, + [NodeShapeType.TRIANGLE]: 4, + [NodeShapeType.TRIANGLE_DOWN]: 5, + [NodeShapeType.STAR]: 6, + [NodeShapeType.HEXAGON]: 7, +}; + +const DEFAULT_FONT_SIZE = 4; +const DEFAULT_FONT_FAMILY = 'Roboto, sans-serif'; +const DEFAULT_FONT_COLOR = '#000000'; +const LABEL_LOD_MIN_SCREEN_PX = 6; +const IMAGE_LOD_MIN_SCREEN_PX = 4; +// Below this zoom, edges render with a simplified fragment path (no SDF, no fwidth, no +// anti-aliasing) since AA shoulders aren't visible anyway. Big win at far-zoom views with +// many overlapping edges, where the per-fragment SDF math dominates GPU time. +const EDGE_SIMPLE_LOD_ZOOM = 0.2; +const LABEL_DISTANCE_FROM_NODE = 0.2; +const FLOATS_PER_LABEL = 8; + +type RGBAFloats = [number, number, number, number]; export class WebGLRenderer extends Emitter implements IRenderer { private readonly _container: HTMLElement; private readonly _canvas: HTMLCanvasElement; // Contains the HTML5 Canvas element which is used for drawing nodes and edges. - private readonly _context: WebGL2RenderingContext; + private readonly _gl: WebGL2RenderingContext; private _width: number; private _height: number; private _settings: IRendererSettings; transform: ZoomTransform; + private _isOriginCentered = false; + private _isInitiallyRendered = false; + + private _nodeProgram: WebGLProgram | null = null; + private _edgeProgram: WebGLProgram | null = null; + private _labelProgram: WebGLProgram | null = null; + + private _nodeVao: WebGLVertexArrayObject | null = null; + private _edgeVao: WebGLVertexArrayObject | null = null; + private _labelVao: WebGLVertexArrayObject | null = null; + + private _nodeInstanceBuffer: WebGLBuffer | null = null; + private _edgeInstanceBuffer: WebGLBuffer | null = null; + private _labelInstanceBuffer: WebGLBuffer | null = null; + + private _labelCache: LabelCache | null = null; + private _imageAtlas: ImageAtlas | null = null; + + private _isColorCacheDirty = true; + private _nodeColorCache = new Map(); + private _nodeBorderColorCache = new Map(); + private _nodeShadowColorCache = new Map(); + + private _edgeColorCache = new Map(); + private _edgeShadowColorCache = new Map(); + + private _lastNodeCount = 0; + private _lastEdgeCount = 0; + + private _edgeInstanceData: Float32Array | null = null; + private _nodeInstanceData: Float32Array | null = null; + private _buffersAreCurrent = false; + private _bufferCacheStats = { hits: 0, misses: 0 }; + + private _timerExt: any = null; + private _timerEdgeQueries: WebGLQuery[] = []; + private _timerNodeQueries: WebGLQuery[] = []; + private _timerQueryIdx = 0; + private _lastEdgeGpuMs: number | null = null; + private _lastNodeGpuMs: number | null = null; + constructor(container: HTMLElement, settings?: Partial) { super(); setupContainer(container, settings?.areCollapsedContainerDimensionsAllowed); this._container = container; this._canvas = appendCanvas(container); - const context = this._canvas.getContext('webgl2'); + const gl = this._canvas.getContext('webgl2', { antialias: true }); - if (!context) { + if (!gl) { throw new OrbError('Failed to create WebGL context.'); } - this._context = context; + this._gl = gl; this._width = DEFAULT_RENDERER_WIDTH; this._height = DEFAULT_RENDERER_HEIGHT; this.transform = zoomIdentity; @@ -48,7 +136,256 @@ export class WebGLRenderer extends Emi ...settings, }; - console.log('context', this._context); + this._initShaders(); + this._initNodeBuffers(); + this._initEdgeBuffers(); + this._initLabelBuffers(); + this._labelCache = new LabelCache(this._gl); + this._imageAtlas = new ImageAtlas(this._gl); + + // GPU timer queries — async, polled lazily. Without this extension getGpuTimeStats + // returns nulls but render() runs identically. + this._timerExt = gl.getExtension('EXT_disjoint_timer_query_webgl2'); + if (this._timerExt) { + for (let i = 0; i < 4; i++) { + const eq = gl.createQuery(); + const nq = gl.createQuery(); + if (eq) { + this._timerEdgeQueries.push(eq); + } + if (nq) { + this._timerNodeQueries.push(nq); + } + } + } + } + + private _pollTimerQuery(query: WebGLQuery): number | null { + if (!this._timerExt) { + return null; + } + const gl = this._gl; + const available = gl.getQueryParameter(query, gl.QUERY_RESULT_AVAILABLE); + if (!available) { + return null; + } + if (gl.getParameter(this._timerExt.GPU_DISJOINT_EXT)) { + return null; + } + const ns = gl.getQueryParameter(query, gl.QUERY_RESULT) as number; + return ns / 1e6; + } + + getGpuTimeStats(): { edgeMs: number | null; nodeMs: number | null; supported: boolean } { + return { + edgeMs: this._lastEdgeGpuMs, + nodeMs: this._lastNodeGpuMs, + supported: this._timerExt !== null, + }; + } + + private _initShaders(): void { + this._nodeProgram = createProgram(this._gl, nodeVertexSource, nodeFragmentSource); + this._edgeProgram = createProgram(this._gl, edgeVertexSource, edgeFragmentSource); + this._labelProgram = createProgram(this._gl, labelVertexSource, labelFragmentSource); + } + + private _initNodeBuffers(): void { + if (!this._nodeProgram) { + throw new OrbError('Node program not initialized.'); + } + + const gl = this._gl; + + this._nodeVao = gl.createVertexArray(); + gl.bindVertexArray(this._nodeVao); + + const quadVerts = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); + const quadBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW); + + const posLoc = gl.getAttribLocation(this._nodeProgram, 'aQuadPosition'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + this._nodeInstanceBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this._nodeInstanceBuffer); + + const INSTANCE_STRIDE = 25 * Float32Array.BYTES_PER_ELEMENT; + const attr = (name: string, size: number, offset: number) => { + const loc = gl.getAttribLocation(this._nodeProgram!, name); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, INSTANCE_STRIDE, offset * 4); + gl.vertexAttribDivisor(loc, 1); + }; + + attr('aCenter', 2, 0); + attr('aRadius', 1, 2); + attr('aColor', 4, 3); + attr('aBorderColor', 4, 7); + attr('aBorderWidth', 1, 11); + attr('aShadowColor', 4, 12); + attr('aShadowSize', 1, 16); + attr('aShadowOffsetX', 1, 17); + attr('aShadowOffsetY', 1, 18); + attr('aShapeType', 1, 19); + attr('aImageUV0', 2, 20); + attr('aImageUV1', 2, 22); + attr('aImageAspect', 1, 24); + + gl.bindVertexArray(null); + } + + private _initEdgeBuffers(): void { + if (!this._edgeProgram) { + throw new OrbError('Edge program not initialized.'); + } + + const gl = this._gl; + + this._edgeVao = gl.createVertexArray(); + gl.bindVertexArray(this._edgeVao); + + const quadVerts = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); + const quadBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW); + + const posLoc = gl.getAttribLocation(this._edgeProgram, 'aQuadPosition'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + this._edgeInstanceBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this._edgeInstanceBuffer); + + const STRIDE = 25 * 4; + const attr = (name: string, size: number, offset: number) => { + const loc = gl.getAttribLocation(this._edgeProgram!, name); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, STRIDE, offset * 4); + gl.vertexAttribDivisor(loc, 1); + }; + + attr('aStart', 2, 0); + attr('aEnd', 2, 2); + attr('aControl', 2, 4); + attr('aWidth', 1, 6); + attr('aEdgeType', 1, 7); + attr('aLoopbackRadius', 1, 8); + attr('aArrowSize', 1, 9); + attr('aArrowTip', 2, 10); + attr('aArrowDir', 2, 12); + attr('aColor', 4, 14); + attr('aShadowColor', 4, 18); + attr('aShadowSize', 1, 22); + attr('aShadowOffsetX', 1, 23); + attr('aShadowOffsetY', 1, 24); + + gl.bindVertexArray(null); + } + + private _initLabelBuffers(): void { + if (!this._labelProgram) { + throw new OrbError('Label program not initialized.'); + } + + const gl = this._gl; + + this._labelVao = gl.createVertexArray(); + gl.bindVertexArray(this._labelVao); + + const quadVerts = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); + const quadBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer); + gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW); + + const posLoc = gl.getAttribLocation(this._labelProgram, 'aQuadPosition'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + + this._labelInstanceBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this._labelInstanceBuffer); + + const STRIDE = FLOATS_PER_LABEL * 4; + const attr = (name: string, size: number, offset: number) => { + const loc = gl.getAttribLocation(this._labelProgram!, name); + gl.enableVertexAttribArray(loc); + gl.vertexAttribPointer(loc, size, gl.FLOAT, false, STRIDE, offset * 4); + gl.vertexAttribDivisor(loc, 1); + }; + + attr('aLabelCenter', 2, 0); + attr('aLabelSize', 2, 2); + attr('aLabelUV0', 2, 4); + attr('aLabelUV1', 2, 6); + + gl.bindVertexArray(null); + } + + private _resolveColor(raw: Color | string | undefined): RGBAFloats { + if (!raw) { + return [1, 0, 0, 1]; + } + + if (raw instanceof Color) { + return [raw.rgb.r / 255, raw.rgb.g / 255, raw.rgb.b / 255, 1.0]; + } + + const rgbaMatch = raw.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/); + if (rgbaMatch) { + return [ + parseInt(rgbaMatch[1]) / 255, + parseInt(rgbaMatch[2]) / 255, + parseInt(rgbaMatch[3]) / 255, + rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1.0, + ]; + } + + const c = new Color(raw); + return [c.rgb.r / 255, c.rgb.g / 255, c.rgb.b / 255, 1.0]; + } + + private _buildNodeColorCache(nodes: INode[]): void { + this._nodeColorCache.clear(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + this._nodeColorCache.set(node.id, this._resolveColor(node.getColor())); + } + } + + private _buildNodeBorderColorCache(nodes: INode[]): void { + this._nodeBorderColorCache.clear(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + this._nodeBorderColorCache.set(node.id, this._resolveColor(node.getBorderColor())); + } + } + + private _buildNodeShadowColorCache(nodes: INode[]): void { + this._nodeShadowColorCache.clear(); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const raw = node.getStyle().shadowColor; + this._nodeShadowColorCache.set(node.id, raw ? this._resolveColor(raw) : [0, 0, 0, 0]); + } + } + + private _buildEdgeColorCache(edges: IEdge[]): void { + this._edgeColorCache.clear(); + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + this._edgeColorCache.set(edge.id, this._resolveColor(edge.getColor())); + } + } + + private _buildEdgeShadowColorCache(edges: IEdge[]): void { + this._edgeShadowColorCache.clear(); + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const raw = edge.getStyle().shadowColor; + this._edgeShadowColorCache.set(edge.id, raw ? this._resolveColor(raw) : [0, 0, 0, 0]); + } } get width(): number { @@ -68,7 +405,15 @@ export class WebGLRenderer extends Emi } get isInitiallyRendered(): boolean { - throw new Error('Method not implemented.'); + return this._isInitiallyRendered; + } + + invalidateBuffers(): void { + this._buffersAreCurrent = false; + } + + getRenderCacheStats(): { hits: number; misses: number } { + return { ...this._bufferCacheStats }; } getSettings(): IRendererSettings { @@ -83,34 +428,579 @@ export class WebGLRenderer extends Emi } render(graph: IGraph): void { - console.log('graph:', graph); - throw new Error('Method not implemented.'); + if (!this._nodeProgram || !this._edgeProgram || !this._labelProgram) { + throw new OrbError('Shader programs not initialized.'); + } + + this.emit(RenderEventType.RENDER_START, undefined); + const renderStartedAt = performance.now(); + + const gl = this._gl; + + const rect = this._container.getBoundingClientRect(); + if (rect.width !== this._width || rect.height !== this._height) { + this._canvas.width = rect.width; + this._canvas.height = rect.height; + this._width = rect.width; + this._height = rect.height; + } + + gl.viewport(0, 0, this._width, this._height); + + gl.clearColor(0.0, 0.0, 0.0, 0.0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + const edges = graph.getEdges(); + const FLOATS_PER_EDGE = 25; + const edgeBufferLen = edges.length * FLOATS_PER_EDGE; + const edgeBufferSizeChanged = this._edgeInstanceData === null || this._edgeInstanceData.length !== edgeBufferLen; + if (edgeBufferSizeChanged) { + this._edgeInstanceData = new Float32Array(edgeBufferLen); + } + const edgeData = this._edgeInstanceData!; + + const canSkipRebuild = this._buffersAreCurrent && !edgeBufferSizeChanged; + if (canSkipRebuild) { + this._bufferCacheStats.hits++; + } else { + this._bufferCacheStats.misses++; + } + + let nodeCxCache: Float64Array | null = null; + let nodeCyCache: Float64Array | null = null; + let nodeBorderCache: Float64Array | null = null; + let nodeIdToIndex: Map | null = null; + if (!canSkipRebuild) { + const allNodes = graph.getNodes(); + nodeCxCache = new Float64Array(allNodes.length); + nodeCyCache = new Float64Array(allNodes.length); + nodeBorderCache = new Float64Array(allNodes.length); + nodeIdToIndex = new Map(); + for (let i = 0; i < allNodes.length; i++) { + const n = allNodes[i]; + const c = n.getCenter(); + nodeCxCache[i] = c.x; + nodeCyCache[i] = c.y; + nodeBorderCache[i] = n.getDistanceToBorder(); + nodeIdToIndex.set(n.id, i); + } + } + + if (!canSkipRebuild && (edges.length !== this._lastEdgeCount || this._isColorCacheDirty)) { + this._buildEdgeColorCache(edges); + this._buildEdgeShadowColorCache(edges); + this._isColorCacheDirty = false; + this._lastEdgeCount = edges.length; + } + + if (!canSkipRebuild) { + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const startIdx = nodeIdToIndex!.get(edge.startNode.id); + const endIdx = nodeIdToIndex!.get(edge.endNode.id); + let startX: number; + let startY: number; + let endX: number; + let endY: number; + let endBorderDist: number; + let startBorderDist: number; + if (startIdx !== undefined) { + startX = nodeCxCache![startIdx]; + startY = nodeCyCache![startIdx]; + startBorderDist = nodeBorderCache![startIdx]; + } else { + const c = edge.startNode.getCenter(); + startX = c.x; + startY = c.y; + startBorderDist = edge.startNode.getDistanceToBorder(); + } + if (endIdx !== undefined) { + endX = nodeCxCache![endIdx]; + endY = nodeCyCache![endIdx]; + endBorderDist = nodeBorderCache![endIdx]; + } else { + const c = edge.endNode.getCenter(); + endX = c.x; + endY = c.y; + endBorderDist = edge.endNode.getDistanceToBorder(); + } + const edgeStyle = edge.getStyle(); + const shadowSize = edgeStyle.shadowSize || 0; + const shadowOffsetX = edgeStyle.shadowOffsetX || 0; + const shadowOffsetY = edgeStyle.shadowOffsetY || 0; + const shadowColor = this._edgeShadowColorCache.get(edge.id) || TRANSPARENT_RGBA; + const off = i * FLOATS_PER_EDGE; + + const width = edge.getWidth(); + const rgba = + edge.isHovered() || edge.isSelected() + ? this._resolveColor(edge.getColor()) + : this._edgeColorCache.get(edge.id) || EDGE_DEFAULT_RGBA; + + let edgeType = EDGE_TYPE_STRAIGHT; + let controlX = 0; + let controlY = 0; + let loopbackRadius = 0; + let arrowSize = 0; + let arrowTipX = 0; + let arrowTipY = 0; + let arrowDirX = 0; + let arrowDirY = 0; + + if (edge.isCurved()) { + edgeType = EDGE_TYPE_CURVED; + const cp = (edge as EdgeCurved).getCurvedControlPoint(); + controlX = cp.x; + controlY = cp.y; + } else if (edge.isLoopback()) { + edgeType = EDGE_TYPE_LOOPBACK; + const circle = (edge as EdgeLoopback).getCircularData(); + controlX = circle.x; + controlY = circle.y; + loopbackRadius = circle.radius; + } + + const scaleFactor = edgeStyle.arrowSize ?? 1; + if (scaleFactor > 0) { + const lineWidth = width || 1; + arrowSize = 1.5 * scaleFactor + 3 * lineWidth; + + if (edgeType === EDGE_TYPE_STRAIGHT) { + const dx = endX - startX; + const dy = endY - startY; + const len = Math.sqrt(dx * dx + dy * dy); + if (len > 0) { + arrowDirX = dx / len; + arrowDirY = dy / len; + arrowTipX = endX - arrowDirX * endBorderDist; + arrowTipY = endY - arrowDirY * endBorderDist; + } + } else if (edgeType === EDGE_TYPE_CURVED) { + // End node's center is (endX, endY); border distance cached above. + let bestT = 1.0; + let low = 0.5; + let high = 1.0; + + for (let iter = 0; iter < 8; iter++) { + const mid = (low + high) * 0.5; + const mt = 1 - mid; + const px = mt * mt * startX + 2 * mid * mt * controlX + mid * mid * endX; + const py = mt * mt * startY + 2 * mid * mt * controlY + mid * mid * endY; + const d = Math.sqrt((px - endX) ** 2 + (py - endY) ** 2); + if (Math.abs(d - endBorderDist) < 0.1) { + bestT = mid; + break; + } + if (d > endBorderDist) { + low = mid; + } else { + high = mid; + } + bestT = mid; + } + + const mt = 1 - bestT; + arrowTipX = mt * mt * startX + 2 * bestT * mt * controlX + bestT * bestT * endX; + arrowTipY = mt * mt * startY + 2 * bestT * mt * controlY + bestT * bestT * endY; + const tx = 2 * mt * (controlX - startX) + 2 * bestT * (endX - controlX); + const ty = 2 * mt * (controlY - startY) + 2 * bestT * (endY - controlY); + const tLen = Math.sqrt(tx * tx + ty * ty); + if (tLen > 0) { + arrowDirX = tx / tLen; + arrowDirY = ty / tLen; + } + } else { + let bestT = 0.8; + let low = 0.6; + let high = 1.0; + for (let iter = 0; iter < 8; iter++) { + const mid = (low + high) * 0.5; + const angle = mid * 2 * Math.PI; + const px = controlX + loopbackRadius * Math.cos(angle); + const py = controlY - loopbackRadius * Math.sin(angle); + const d = Math.sqrt((px - startX) ** 2 + (py - startY) ** 2); + if (Math.abs(d - startBorderDist) < 0.1) { + bestT = mid; + break; + } + if (d > startBorderDist) { + high = mid; + } else { + low = mid; + } + bestT = mid; + } + const angle = bestT * 2 * Math.PI; + arrowTipX = controlX + loopbackRadius * Math.cos(angle); + arrowTipY = controlY - loopbackRadius * Math.sin(angle); + const arrowAngle = bestT * -2 * Math.PI + 0.45 * Math.PI; + arrowDirX = Math.cos(arrowAngle); + arrowDirY = Math.sin(arrowAngle); + } + } + + edgeData[off] = startX; + edgeData[off + 1] = startY; + edgeData[off + 2] = endX; + edgeData[off + 3] = endY; + edgeData[off + 4] = controlX; + edgeData[off + 5] = controlY; + edgeData[off + 6] = width; + edgeData[off + 7] = edgeType; + edgeData[off + 8] = loopbackRadius; + edgeData[off + 9] = arrowSize; + edgeData[off + 10] = arrowTipX; + edgeData[off + 11] = arrowTipY; + edgeData[off + 12] = arrowDirX; + edgeData[off + 13] = arrowDirY; + edgeData[off + 14] = rgba[0]; + edgeData[off + 15] = rgba[1]; + edgeData[off + 16] = rgba[2]; + edgeData[off + 17] = rgba[3]; + edgeData[off + 18] = shadowColor[0]; + edgeData[off + 19] = shadowColor[1]; + edgeData[off + 20] = shadowColor[2]; + edgeData[off + 21] = shadowColor[3]; + edgeData[off + 22] = shadowSize; + edgeData[off + 23] = shadowOffsetX; + edgeData[off + 24] = shadowOffsetY; + } + } + + gl.useProgram(this._edgeProgram); + this._setViewUniforms(this._edgeProgram); + gl.bindBuffer(gl.ARRAY_BUFFER, this._edgeInstanceBuffer); + if (!canSkipRebuild) { + gl.bufferData(gl.ARRAY_BUFFER, edgeData.byteLength, gl.STREAM_DRAW); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, edgeData); + } + + const isSimpleEdgeMode = this.transform.k <= EDGE_SIMPLE_LOD_ZOOM; + const uSimpleModeLoc = gl.getUniformLocation(this._edgeProgram, 'uSimpleMode'); + gl.uniform1i(uSimpleModeLoc, isSimpleEdgeMode ? 1 : 0); + + gl.bindVertexArray(this._edgeVao); + + if (this._timerExt && this._timerEdgeQueries.length > 0) { + const slot = this._timerEdgeQueries[this._timerQueryIdx]; + const ms = this._pollTimerQuery(slot); + if (ms !== null) { + this._lastEdgeGpuMs = ms; + } + gl.beginQuery(this._timerExt.TIME_ELAPSED_EXT, slot); + } + + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, edges.length); + + if (this._timerExt && this._timerEdgeQueries.length > 0) { + gl.endQuery(this._timerExt.TIME_ELAPSED_EXT); + } + gl.bindVertexArray(null); + + if (isSimpleEdgeMode) { + gl.enable(gl.BLEND); + } + + gl.useProgram(this._nodeProgram); + this._setViewUniforms(this._nodeProgram); + + if (this._imageAtlas) { + this._imageAtlas.uploadIfDirty(); + this._imageAtlas.bind(0); + gl.uniform1i(gl.getUniformLocation(this._nodeProgram, 'uImageAtlas'), 0); + } + + const nodes = graph.getNodes(); + const zoom = this.transform.k; + const FLOATS_PER_NODE = 25; + const nodeBufferLen = nodes.length * FLOATS_PER_NODE; + const nodeBufferSizeChanged = this._nodeInstanceData === null || this._nodeInstanceData.length !== nodeBufferLen; + if (nodeBufferSizeChanged) { + this._nodeInstanceData = new Float32Array(nodeBufferLen); + } + const instanceData = this._nodeInstanceData!; + const canSkipNodeRebuild = canSkipRebuild && !nodeBufferSizeChanged; + + if (!canSkipNodeRebuild && (nodes.length !== this._lastNodeCount || this._isColorCacheDirty)) { + this._buildNodeColorCache(nodes); + this._buildNodeBorderColorCache(nodes); + this._buildNodeShadowColorCache(nodes); + this._isColorCacheDirty = false; + this._lastNodeCount = nodes.length; + } + + if (!canSkipNodeRebuild) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const center = node.getCenter(); + const radius = node.getRadius(); + const nodeStyle = node.getStyle(); + const shadowSize = nodeStyle.shadowSize || 0; + const shadowOffsetX = nodeStyle.shadowOffsetX || 0; + const shadowOffsetY = nodeStyle.shadowOffsetY || 0; + const shadowColor = this._nodeShadowColorCache.get(node.id) || TRANSPARENT_RGBA; + const off = i * FLOATS_PER_NODE; + + let rgba: RGBAFloats; + let borderColor: RGBAFloats; + let borderWidth: number; + + if (node.isHovered() || node.isSelected()) { + rgba = this._resolveColor(node.getColor()); + borderColor = this._resolveColor(node.getBorderColor()); + borderWidth = node.getBorderWidth(); + } else { + rgba = this._nodeColorCache.get(node.id) || NODE_DEFAULT_RGBA; + borderColor = this._nodeBorderColorCache.get(node.id) || TRANSPARENT_RGBA; + borderWidth = node.getBorderWidth(); + } + + instanceData[off] = center.x; + instanceData[off + 1] = center.y; + instanceData[off + 2] = radius; + instanceData[off + 3] = rgba[0]; + instanceData[off + 4] = rgba[1]; + instanceData[off + 5] = rgba[2]; + instanceData[off + 6] = rgba[3]; + instanceData[off + 7] = borderColor[0]; + instanceData[off + 8] = borderColor[1]; + instanceData[off + 9] = borderColor[2]; + instanceData[off + 10] = borderColor[3]; + instanceData[off + 11] = borderWidth; + instanceData[off + 12] = shadowColor[0]; + instanceData[off + 13] = shadowColor[1]; + instanceData[off + 14] = shadowColor[2]; + instanceData[off + 15] = shadowColor[3]; + instanceData[off + 16] = shadowSize; + instanceData[off + 17] = shadowOffsetX; + instanceData[off + 18] = shadowOffsetY; + instanceData[off + 19] = SHAPE_TYPE_MAP[nodeStyle.shape ?? NodeShapeType.CIRCLE] ?? 0; + + let imgU0 = 0; + let imgV0 = 0; + let imgU1 = 0; + let imgV1 = 0; + let imgAspect = 0; + if (radius * zoom >= IMAGE_LOD_MIN_SCREEN_PX) { + const imageUrl = node.isSelected() ? nodeStyle.imageUrlSelected || nodeStyle.imageUrl : nodeStyle.imageUrl; + if (imageUrl && this._imageAtlas) { + const entry = this._imageAtlas.getOrCreate(imageUrl); + if (entry) { + imgU0 = entry.u0; + imgV0 = entry.v0; + imgU1 = entry.u1; + imgV1 = entry.v1; + imgAspect = entry.aspect; + } + } + } + instanceData[off + 20] = imgU0; + instanceData[off + 21] = imgV0; + instanceData[off + 22] = imgU1; + instanceData[off + 23] = imgV1; + instanceData[off + 24] = imgAspect; + } + } + + gl.bindBuffer(gl.ARRAY_BUFFER, this._nodeInstanceBuffer); + if (!canSkipNodeRebuild) { + gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.STREAM_DRAW); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceData); + } + this._buffersAreCurrent = true; + + gl.bindVertexArray(this._nodeVao); + + if (this._timerExt && this._timerNodeQueries.length > 0) { + const slot = this._timerNodeQueries[this._timerQueryIdx]; + const ms = this._pollTimerQuery(slot); + if (ms !== null) { + this._lastNodeGpuMs = ms; + } + gl.beginQuery(this._timerExt.TIME_ELAPSED_EXT, slot); + } + + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, nodes.length); + + if (this._timerExt && this._timerNodeQueries.length > 0) { + gl.endQuery(this._timerExt.TIME_ELAPSED_EXT); + this._timerQueryIdx = (this._timerQueryIdx + 1) % this._timerNodeQueries.length; + } + gl.bindVertexArray(null); + + if (this._labelProgram && this._labelCache && this._settings.labelsIsEnabled) { + const labelCache = this._labelCache; + const rasterPx = labelCache.rasterFontPx; + let labelCount = 0; + + const maxLabels = nodes.length + edges.length; + const labelData = new Float32Array(maxLabels * FLOATS_PER_LABEL); + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const text = node.getLabel(); + if (!text) { + continue; + } + + const style = node.getStyle(); + const fontSize = style.fontSize || DEFAULT_FONT_SIZE; + if (fontSize * zoom < LABEL_LOD_MIN_SCREEN_PX) { + continue; + } + + const fontFamily = style.fontFamily || DEFAULT_FONT_FAMILY; + const fontColor = (style.fontColor ?? DEFAULT_FONT_COLOR).toString(); + const bgColor = style.fontBackgroundColor ? style.fontBackgroundColor.toString() : null; + + const entry = labelCache.getOrCreate(text, fontSize, fontFamily, fontColor, bgColor); + if (!entry) { + continue; + } + + const center = node.getCenter(); + const borderedRadius = node.getBorderedRadius(); + const worldW = (entry.pxWidth / rasterPx) * fontSize; + const worldH = (entry.pxHeight / rasterPx) * fontSize; + + const off = labelCount * FLOATS_PER_LABEL; + labelData[off] = center.x; + labelData[off + 1] = center.y + borderedRadius * (1 + LABEL_DISTANCE_FROM_NODE) + worldH / 2; + labelData[off + 2] = worldW / 2; + labelData[off + 3] = worldH / 2; + labelData[off + 4] = entry.u0; + labelData[off + 5] = entry.v0; + labelData[off + 6] = entry.u1; + labelData[off + 7] = entry.v1; + labelCount++; + } + + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const text = edge.getLabel(); + if (!text) { + continue; + } + + const style = edge.getStyle(); + const fontSize = style.fontSize || DEFAULT_FONT_SIZE; + if (fontSize * zoom < LABEL_LOD_MIN_SCREEN_PX) { + continue; + } + + const fontFamily = style.fontFamily || DEFAULT_FONT_FAMILY; + const fontColor = (style.fontColor ?? DEFAULT_FONT_COLOR).toString(); + const bgColor = style.fontBackgroundColor ? style.fontBackgroundColor.toString() : null; + + const entry = labelCache.getOrCreate(text, fontSize, fontFamily, fontColor, bgColor); + if (!entry) { + continue; + } + + const edgeCenter = edge.getCenter(); + const worldW = (entry.pxWidth / rasterPx) * fontSize; + const worldH = (entry.pxHeight / rasterPx) * fontSize; + + const off = labelCount * FLOATS_PER_LABEL; + labelData[off] = edgeCenter.x; + labelData[off + 1] = edgeCenter.y; + labelData[off + 2] = worldW / 2; + labelData[off + 3] = worldH / 2; + labelData[off + 4] = entry.u0; + labelData[off + 5] = entry.v0; + labelData[off + 6] = entry.u1; + labelData[off + 7] = entry.v1; + labelCount++; + } + + if (labelCount > 0) { + labelCache.uploadIfDirty(); + + gl.useProgram(this._labelProgram); + this._setViewUniforms(this._labelProgram); + + labelCache.bind(0); + gl.uniform1i(gl.getUniformLocation(this._labelProgram, 'uAtlas'), 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, this._labelInstanceBuffer); + gl.bufferData(gl.ARRAY_BUFFER, labelData.subarray(0, labelCount * FLOATS_PER_LABEL), gl.DYNAMIC_DRAW); + + gl.bindVertexArray(this._labelVao); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, labelCount); + gl.bindVertexArray(null); + } + } + + this._isInitiallyRendered = true; + this.emit(RenderEventType.RENDER_END, { durationMs: performance.now() - renderStartedAt }); } reset(): void { - throw new Error('Method not implemented.'); + this.transform = zoomIdentity; + const gl = this._gl; + gl.clearColor(0.0, 0.0, 0.0, 0.0); + gl.clear(gl.COLOR_BUFFER_BIT); } getFitZoomTransform(graph: IGraph): ZoomTransform { - console.log('graph:', graph); - throw new Error('Method not implemented.'); + const graphView = graph.getBoundingBox(); + const graphMiddleX = graphView.x + graphView.width / 2; + const graphMiddleY = graphView.y + graphView.height / 2; + + const simulationView = this.getSimulationViewRectangle(); + + const heightScale = simulationView.height / (graphView.height * (1 + this._settings.fitZoomMargin)); + const widthScale = simulationView.width / (graphView.width * (1 + this._settings.fitZoomMargin)); + const scale = Math.min(heightScale, widthScale); + + const previousZoom = this.transform.k; + const newZoom = Math.max(Math.min(scale * previousZoom, this._settings.maxZoom), this._settings.minZoom); + + const newX = (simulationView.width / 2) * previousZoom * (1 - newZoom) - graphMiddleX * newZoom; + const newY = (simulationView.height / 2) * previousZoom * (1 - newZoom) - graphMiddleY * newZoom; + + return zoomIdentity.translate(newX, newY).scale(newZoom); } getSimulationPosition(canvasPoint: IPosition): IPosition { - console.log('canvasPoint:', canvasPoint); - throw new Error('Method not implemented.'); + const [x, y] = this.transform.invert([canvasPoint.x, canvasPoint.y]); + return { + x: x - this._width / 2, + y: y - this._height / 2, + }; } getSimulationViewRectangle(): IRectangle { - throw new Error('Method not implemented.'); + const topLeftPosition = this.getSimulationPosition({ x: 0, y: 0 }); + const bottomRightPosition = this.getSimulationPosition({ x: this._width, y: this._height }); + return { + x: topLeftPosition.x, + y: topLeftPosition.y, + width: bottomRightPosition.x - topLeftPosition.x, + height: bottomRightPosition.y - topLeftPosition.y, + }; } translateOriginToCenter(): void { - throw new Error('Method not implemented.'); + this._isOriginCentered = true; } destroy(): void { this.removeAllListeners(); this._canvas.outerHTML = ''; } + + private _setViewUniforms(program: WebGLProgram): void { + const gl = this._gl; + const originX = this._isOriginCentered ? this._width / 2 : 0; + const originY = this._isOriginCentered ? this._height / 2 : 0; + + gl.uniform2f(gl.getUniformLocation(program, 'uResolution'), this._width, this._height); + gl.uniform2f(gl.getUniformLocation(program, 'uTranslation'), this.transform.x, this.transform.y); + gl.uniform1f(gl.getUniformLocation(program, 'uScale'), this.transform.k); + gl.uniform2f(gl.getUniformLocation(program, 'uOriginOffset'), originX, originY); + } } diff --git a/src/simulator/engine/engines/dynamic/force-layout-engine.ts b/src/simulator/engine/engines/dynamic/force-layout-engine.ts index 40dd4f8..fb9d824 100644 --- a/src/simulator/engine/engines/dynamic/force-layout-engine.ts +++ b/src/simulator/engine/engines/dynamic/force-layout-engine.ts @@ -11,7 +11,13 @@ import { SimulationLinkDatum, } from 'd3-force'; import { IPosition } from '../../../../common'; -import { ISimulationNode, ISimulationGraph, ISimulationIds, SimulatorEventType } from '../../../shared'; +import { + ISimulationNode, + ISimulationEdge, + ISimulationGraph, + ISimulationIds, + SimulatorEventType, +} from '../../../shared'; import { isObjectEqual, copyObject } from '../../../../utils/object.utils'; import { IEngineSettingsUpdate, IForceLayoutOptions, DEFAULT_FORCE_LAYOUT_OPTIONS, LayoutType } from '../../shared'; import { BaseLayoutEngine } from '../base-layout-engine'; @@ -23,6 +29,49 @@ interface IRunSimulationOptions { isUpdatingSettings: boolean; } +function forceEdgeMidpointRepulsion(strength: number, distanceMax: number, getEdges: () => ISimulationEdge[]) { + let nodes: ISimulationNode[] = []; + const distanceMax2 = distanceMax * distanceMax; + + function force(alpha: number) { + const edges = getEdges(); + for (let e = 0; e < edges.length; e++) { + const src = edges[e].source as ISimulationNode; + const tgt = edges[e].target as ISimulationNode; + if (!src || !tgt) { + continue; + } + + const mx = ((src.x ?? 0) + (tgt.x ?? 0)) * 0.5; + const my = ((src.y ?? 0) + (tgt.y ?? 0)) * 0.5; + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const dx = (node.x ?? 0) - mx; + const dy = (node.y ?? 0) - my; + let distSq = dx * dx + dy * dy; + + if (distSq === 0 || distSq >= distanceMax2) { + continue; + } + if (distSq < 1) { + distSq = 1; + } + + const f = (-strength * alpha) / distSq; + node.vx! += dx * f; + node.vy! += dy * f; + } + } + } + + force.initialize = (initNodes: ISimulationNode[]) => { + nodes = initNodes; + }; + + return force; +} + export class ForceLayoutEngine extends BaseLayoutEngine { private _linkForce!: ForceLink>; private _simulation!: Simulation; @@ -470,10 +519,24 @@ export class ForceLayoutEngine extends BaseLayoutEngine { .distanceMin(settings.manyBody.distanceMin) .distanceMax(settings.manyBody.distanceMax); this._simulation.force('charge', manyBody); + + if (settings.manyBody.edgeMidpointRepulsion) { + this._simulation.force( + 'edgeMidpointRepulsion', + forceEdgeMidpointRepulsion( + settings.manyBody.strength, + settings.manyBody.distanceMax, + () => this._edges, + ) as any, + ); + } else { + this._simulation.force('edgeMidpointRepulsion', null); + } } if (settings.manyBody === null) { this._simulation.force('charge', null); + this._simulation.force('edgeMidpointRepulsion', null); } if (settings.positioning?.forceX) { diff --git a/src/simulator/engine/engines/dynamic/gpu-force-layout-engine.ts b/src/simulator/engine/engines/dynamic/gpu-force-layout-engine.ts new file mode 100644 index 0000000..e9ea3af --- /dev/null +++ b/src/simulator/engine/engines/dynamic/gpu-force-layout-engine.ts @@ -0,0 +1,1021 @@ +import { IPosition } from '../../../../common'; +import { ISimulationNode, ISimulationGraph, ISimulationIds, SimulatorEventType } from '../../../shared'; +import { copyObject, isObjectEqual } from '../../../../utils/object.utils'; +import { + IEngineSettingsUpdate, + IForceLayoutOptions, + DEFAULT_FORCE_LAYOUT_OPTIONS, + LayoutType, + getManyBodyMaxDistance, +} from '../../shared'; +import { BaseLayoutEngine } from '../base-layout-engine'; +import { compileShader, ShaderType } from '../../../../utils/shaders.utils'; +import forceVertSource from '../../shaders/force/force.vert'; +import forceFragSource from '../../shaders/force/force.frag'; +import { OrbError } from '../../../../exceptions'; +import { buildQuadTree } from '../../utils/quadtree-builder'; +import { buildAdjacency, IAdjacencyResult } from '../../utils/adjacency-builder'; + +const MAX_SIMULATION_STEPS = 500; +const CHUNK_SIZE = 1; + +export class GPUForceLayoutEngine extends BaseLayoutEngine { + private readonly _gl: WebGL2RenderingContext; + + private _settings: IForceLayoutOptions; + private _initialSettings: IForceLayoutOptions | undefined; + + private _isStabilizing = false; + private _isDragging = false; + private _dragLoopRunning = false; + private _pendingRestart = false; + private _simulationGeneration = 0; + + private _currentAlpha = 0; + private _currentStep = 0; + private _totalSteps = 0; + + private _dragAlpha = 0; + private _dragNeedsReheat = false; + + private _dirtyNodes: Set = new Set(); + + private _forceProgram: WebGLProgram | null = null; + private _quadBuffer: WebGLBuffer | null = null; + private _quadVAO: WebGLVertexArrayObject | null = null; + + private _stateTexA: WebGLTexture | null = null; + private _stateTexB: WebGLTexture | null = null; + private _fixedTex: WebGLTexture | null = null; + private _fboA: WebGLFramebuffer | null = null; + private _fboB: WebGLFramebuffer | null = null; + private _texWidth = 0; + + private _treeDataTexture: WebGLTexture | null = null; + private _treeChildrenTexture: WebGLTexture | null = null; + private _treeGeometryTexture: WebGLTexture | null = null; + private _adjOffsetsTexture: WebGLTexture | null = null; + private _adjEdgesTexture: WebGLTexture | null = null; + + private _cachedAdjacency: IAdjacencyResult | null = null; + private _treeTexWidth = 1; + private _treeNodeCount = 0; + + private _pingPong = true; + + private _uniforms: Record = {}; + + readonly type: LayoutType = 'force'; + + constructor(options?: IForceLayoutOptions) { + super(); + this._settings = { + ...DEFAULT_FORCE_LAYOUT_OPTIONS, + ...options, + }; + + const gl = document.createElement('canvas').getContext('webgl2'); + if (!gl) { + throw new OrbError('Failed to create WebGL2 context for GPU force layout engine.'); + } + this._gl = gl; + + this._initGPU(); + this.clearData(); + } + + setSettings(settings: IEngineSettingsUpdate) { + const forceSettings = settings as Partial; + + if (!this._initialSettings) { + this._initialSettings = Object.assign(copyObject(DEFAULT_FORCE_LAYOUT_OPTIONS), forceSettings); + } + + const previousSettings = copyObject(this._settings); + Object.assign(this._settings, forceSettings); + + if (isObjectEqual(this._settings, previousSettings)) { + return; + } + + this.emit(SimulatorEventType.SETTINGS_UPDATE, { + settings: { type: 'force', options: this._settings }, + }); + + const hasPhysicsBeenDisabled = previousSettings.isPhysicsEnabled && !forceSettings.isPhysicsEnabled; + + if (hasPhysicsBeenDisabled) { + this.stopSimulation(); + } else if (this._settings.isSimulatingOnSettingsUpdate && this._nodes.length > 0) { + this.activateSimulation(); + } + } + + setupData(data: ISimulationGraph) { + this.clearData(); + this._initializeNewData(data); + + if (this._settings.isSimulatingOnDataUpdate) { + this._runSimulation(); + } + } + + mergeData(data: ISimulationGraph) { + this._initializeNewData(data); + + if (!this._settings.isPhysicsEnabled) { + this._pinNodes(); + } + + if (this._settings.isSimulatingOnDataUpdate) { + this.activateSimulation(); + } + } + + updateData(data: ISimulationGraph) { + const newNodeIds = new Set(data.nodes.map((node) => node.id)); + const oldNodes = this._nodes.filter((node) => newNodeIds.has(node.id)); + const newNodes = data.nodes.filter((node) => this._nodeIndexByNodeId[node.id] === undefined); + + this._nodes = [...oldNodes, ...newNodes]; + this._rebuildNodeIndex(); + this._edges = data.edges; + this._cachedAdjacency = null; + + if (this._settings.isSimulatingOnSettingsUpdate) { + this.activateSimulation(); + } + } + + deleteData(data: Partial) { + if (data.nodeIds) { + const nodeIds = new Set(data.nodeIds); + this._nodes = this._nodes.filter((node) => !nodeIds.has(node.id)); + } + if (data.edgeIds) { + const edgeIds = new Set(data.edgeIds); + this._edges = this._edges.filter((edge) => !edgeIds.has(edge.id)); + } + this._rebuildNodeIndex(); + this._cachedAdjacency = null; + + if (this._settings.isSimulatingOnDataUpdate) { + this.activateSimulation(); + } + } + + patchData(data: Partial) { + if (data.nodes) { + const nodeIds: { [id: number]: number } = {}; + + for (let i = 0; i < this._nodes.length; i++) { + nodeIds[this._nodes[i].id] = i; + } + + for (let i = 0; i < data.nodes.length; i += 1) { + const nodeId: number = data.nodes[i].id; + + if (nodeId in nodeIds) { + const index = nodeIds[nodeId]; + this._nodeIndexByNodeId[nodeId] = index; + this._nodes[index] = data.nodes[i]; + } else { + this._nodes.push(data.nodes[i]); + } + } + } + + if (data.edges) { + const edgeIds: { [id: number]: number } = {}; + for (let i = 0; i < this._edges.length; i++) { + edgeIds[this._edges[i].id] = i; + } + for (let i = 0; i < data.edges.length; i++) { + const edgeId = data.edges[i].id; + if (edgeId in edgeIds) { + this._edges[edgeIds[edgeId]] = data.edges[i]; + } else { + this._edges.push(data.edges[i]); + } + } + } + } + + clearData() { + this._nodes = []; + this._edges = []; + this._rebuildNodeIndex(); + this._cachedAdjacency = null; + } + + activateSimulation() { + if (this._settings.isPhysicsEnabled) { + this._unpinNodes(); + } else { + this._pinNodes(); + } + + if (this._isStabilizing) { + this._pendingRestart = true; + return; + } + + this._ensurePositions(); + this._uploadDataToGPU(); + if (!this._cachedAdjacency) { + this._buildAndUploadAdjacency(); + } + + this._startSimulationLoop(); + } + + stopSimulation() { + if (this._isStabilizing) { + this._cancelSimulation = true; + } + // Don't set flag when nothing is running — it would linger and block future simulations + } + + startDragNode() { + this._isDragging = true; + + // Stop the full simulation if running — drag uses its own lightweight loop + if (this._isStabilizing) { + this._cancelSimulation = true; + } + + if (this._settings.isPhysicsEnabled) { + this._startDragLoop(); + } + } + + dragNode(nodeId: number, position: IPosition) { + const nodeIndex = this._nodeIndexByNodeId[nodeId]; + const node = this._nodes[nodeIndex]; + if (!node) { + return; + } + + if (!this._isDragging) { + this.startDragNode(); + } + + node.fx = position.x; + node.fy = position.y; + + if (!this._settings.isPhysicsEnabled) { + node.x = position.x; + node.y = position.y; + } + + this._dirtyNodes.add(nodeIndex); + this._dragNeedsReheat = true; + + this.emit(SimulatorEventType.NODE_DRAG, { nodes: this._nodes, edges: this._edges }); + } + + endDragNode(nodeId: number) { + this._isDragging = false; + + const node = this._nodes[this._nodeIndexByNodeId[nodeId]]; + if (node && this._settings.isPhysicsEnabled) { + this._unpinNode(node); + const nodeIndex = this._nodeIndexByNodeId[nodeId]; + this._dirtyNodes.add(nodeIndex); + } + } + + fixNodes(nodes?: ISimulationNode[]) { + if (!nodes) { + nodes = this._nodes; + } + for (let i = 0; i < nodes.length; i++) { + this._stickNode(nodes[i]); + } + } + + releaseNodes(nodes?: ISimulationNode[]) { + if (!nodes) { + nodes = this._nodes; + } + for (let i = 0; i < nodes.length; i++) { + this._unstickNode(nodes[i]); + } + + if (this._settings.isSimulatingOnUnstick && this._nodes.length > 0) { + this.activateSimulation(); + } + } + + terminate(): void { + super.terminate(); + + const gl = this._gl; + if (gl) { + gl.deleteBuffer(this._quadBuffer); + gl.deleteVertexArray(this._quadVAO); + gl.deleteProgram(this._forceProgram); + gl.deleteTexture(this._stateTexA); + gl.deleteTexture(this._stateTexB); + gl.deleteTexture(this._fixedTex); + gl.deleteTexture(this._treeDataTexture); + gl.deleteTexture(this._treeChildrenTexture); + gl.deleteTexture(this._treeGeometryTexture); + gl.deleteTexture(this._adjOffsetsTexture); + gl.deleteTexture(this._adjEdgesTexture); + gl.deleteFramebuffer(this._fboA); + gl.deleteFramebuffer(this._fboB); + gl.getExtension('WEBGL_lose_context')?.loseContext(); + } + } + + reheat() { + const alphaSettings = this._settings.alpha; + + this._currentAlpha = alphaSettings.alpha; + this._totalSteps = Math.min( + MAX_SIMULATION_STEPS, + Math.ceil(Math.log(alphaSettings.alphaMin) / Math.log(1 - alphaSettings.alphaDecay)), + ); + this._currentStep = 0; + + if (this._isStabilizing) { + return; + } + + this._ensurePositions(); + this._uploadDataToGPU(); + if (!this._cachedAdjacency) { + this._buildAndUploadAdjacency(); + } + this._startSimulationLoop(); + } + + private _runSimulation(): void { + if (this._isStabilizing || this._cancelSimulation) { + return; + } + + this._ensurePositions(); + this._uploadDataToGPU(); + this._buildAndUploadAdjacency(); + this._startSimulationLoop(); + } + + private _startDragLoop(): void { + if (this._dragLoopRunning) { + return; + } + this._dragLoopRunning = true; + + const alphaDecay = this._settings.alpha.alphaDecay; + const alphaMin = this._settings.alpha.alphaMin; + + this._dragAlpha = 0.3; + this._dragNeedsReheat = false; + + const tick = () => { + if (!this._isDragging) { + this._dragLoopRunning = false; + return; + } + + if (this._dragNeedsReheat) { + this._dragAlpha = 0.3; + this._dragNeedsReheat = false; + } + + this._dragAlpha += (0 - this._dragAlpha) * alphaDecay; + + if (this._dragAlpha < alphaMin) { + requestAnimationFrame(tick); + return; + } + + this._readbackFromGPU(); + this._flushDirtyNodes(); + this._buildAndUploadQuadTree(); + + this._simulateGPUStep(this._dragAlpha); + + this._readbackFromGPU(); + this._applyCentering(); + + this.emit(SimulatorEventType.NODE_DRAG, { nodes: this._nodes, edges: this._edges }); + + requestAnimationFrame(tick); + }; + + requestAnimationFrame(tick); + } + + private _startSimulationLoop(): void { + if (this._isStabilizing || this._cancelSimulation) { + return; + } + + this.emit(SimulatorEventType.SIMULATION_START, undefined); + this._isStabilizing = true; + this._pendingRestart = false; + const generation = ++this._simulationGeneration; + + const alphaSettings = this._settings.alpha; + const alphaMin = alphaSettings.alphaMin; + const alphaDecay = alphaSettings.alphaDecay; + + this._currentAlpha = alphaSettings.alpha; + this._totalSteps = Math.min(MAX_SIMULATION_STEPS, Math.ceil(Math.log(alphaMin) / Math.log(1 - alphaDecay))); + this._currentStep = 0; + + let lastProgress = -1; + + const runChunk = () => { + if (generation !== this._simulationGeneration) { + return; + } + + if (this._cancelSimulation) { + this._isStabilizing = false; + this._cancelSimulation = false; + this.emit(SimulatorEventType.SIMULATION_END, { nodes: this._nodes, edges: this._edges }); + return; + } + + this._readbackFromGPU(); + + if (this._pendingRestart) { + this._isStabilizing = false; + this._pendingRestart = false; + this._ensurePositions(); + this._uploadDataToGPU(); + if (!this._cachedAdjacency) { + this._buildAndUploadAdjacency(); + } + this._startSimulationLoop(); + return; + } + + this._flushDirtyNodes(); + this._buildAndUploadQuadTree(); + + const end = Math.min(this._currentStep + CHUNK_SIZE, this._totalSteps); + + for (; this._currentStep < end; this._currentStep++) { + this._currentAlpha += (alphaSettings.alphaTarget - this._currentAlpha) * alphaDecay; + if (this._currentAlpha < alphaMin || this._cancelSimulation) { + this._currentStep = this._totalSteps; + break; + } + this._simulateGPUStep(this._currentAlpha); + } + + this._readbackFromGPU(); + this._applyCentering(); + + const currentProgress = Math.round((this._currentStep * 100) / this._totalSteps); + if (currentProgress > lastProgress) { + lastProgress = currentProgress; + this.emit(SimulatorEventType.SIMULATION_PROGRESS, { + nodes: this._nodes, + edges: this._edges, + progress: currentProgress / 100, + }); + } + + if (this._currentStep < this._totalSteps && !this._cancelSimulation) { + this._scheduleNext(runChunk); + } else { + if (!this._settings.isPhysicsEnabled) { + this._pinNodes(); + } + + this._isStabilizing = false; + this._cancelSimulation = false; + this.emit(SimulatorEventType.SIMULATION_END, { nodes: this._nodes, edges: this._edges }); + } + }; + + this._scheduleNext(runChunk); + } + + private _ensurePositions(): void { + const linkDist = this._settings.links?.distance ?? 50; + const spread = linkDist * Math.sqrt(this._nodes.length); + for (const node of this._nodes) { + if (node.x === undefined || node.x === null) { + node.x = (Math.random() - 0.5) * spread; + } + if (node.y === undefined || node.y === null) { + node.y = (Math.random() - 0.5) * spread; + } + } + } + + private _applyCentering(): void { + const c = this._settings.centering; + if (!c) { + return; + } + const N = this._nodes.length; + if (N === 0) { + return; + } + let sx = 0; + let sy = 0; + for (let i = 0; i < N; i++) { + sx += this._nodes[i].x ?? 0; + sy += this._nodes[i].y ?? 0; + } + const dx = (sx / N - c.x) * c.strength; + const dy = (sy / N - c.y) * c.strength; + if (Math.abs(dx) < 1e-6 && Math.abs(dy) < 1e-6) { + return; + } + for (let i = 0; i < N; i++) { + const node = this._nodes[i]; + // Don't shift fixed/dragged nodes — their position must stay at fx/fy + if (node.fx !== null && node.fx !== undefined) { + continue; + } + node.x = (node.x ?? 0) - dx; + node.y = (node.y ?? 0) - dy; + } + + this._syncStateToGPU(); + } + + private _syncStateToGPU(): void { + const N = this._nodes.length; + if (N === 0) { + return; + } + + const texSize = this._texWidth * this._texWidth; + const stateData = new Float32Array(texSize * 4); + + for (let i = 0; i < N; i++) { + const node = this._nodes[i]; + const off = i * 4; + stateData[off] = node.x ?? 0; + stateData[off + 1] = node.y ?? 0; + stateData[off + 2] = node.vx ?? 0; + stateData[off + 3] = node.vy ?? 0; + } + + this._uploadTexture(this._stateTexA!, stateData, this._texWidth); + this._uploadTexture(this._stateTexB!, stateData, this._texWidth); + this._pingPong = true; + } + + private _flushDirtyNodes(): void { + if (this._dirtyNodes.size === 0) { + return; + } + + const gl = this._gl; + + for (const nodeIndex of this._dirtyNodes) { + const node = this._nodes[nodeIndex]; + if (!node) { + continue; + } + + const col = nodeIndex % this._texWidth; + const row = Math.floor(nodeIndex / this._texWidth); + + const statePixel = new Float32Array([node.x ?? 0, node.y ?? 0, node.vx ?? 0, node.vy ?? 0]); + + gl.bindTexture(gl.TEXTURE_2D, this._stateTexA); + gl.texSubImage2D(gl.TEXTURE_2D, 0, col, row, 1, 1, gl.RGBA, gl.FLOAT, statePixel); + gl.bindTexture(gl.TEXTURE_2D, this._stateTexB); + gl.texSubImage2D(gl.TEXTURE_2D, 0, col, row, 1, 1, gl.RGBA, gl.FLOAT, statePixel); + + const fixedPixel = new Float32Array([ + node.fx !== null && node.fx !== undefined ? 1.0 : 0.0, + node.fx ?? node.x ?? 0, + node.fy ?? node.y ?? 0, + 0.0, + ]); + + gl.bindTexture(gl.TEXTURE_2D, this._fixedTex); + gl.texSubImage2D(gl.TEXTURE_2D, 0, col, row, 1, 1, gl.RGBA, gl.FLOAT, fixedPixel); + } + + this._dirtyNodes.clear(); + } + + private _buildAndUploadQuadTree(): void { + const N = this._nodes.length; + if (N === 0) { + return; + } + + const strength = this._settings.manyBody?.strength ?? -100; + + let positions: { x: number; y: number }[] = this._nodes as { x: number; y: number }[]; + if (this._settings.manyBody?.edgeMidpointRepulsion) { + const edgeMidpoints = this._getEdgeMidpoints(); + if (edgeMidpoints.length > 0) { + positions = positions.concat(edgeMidpoints); + } + } + + const tree = buildQuadTree(positions, strength); + + this._uploadTexture(this._treeDataTexture!, tree.treeData, tree.texWidth); + this._uploadTexture(this._treeChildrenTexture!, tree.treeChildren, tree.texWidth); + this._uploadTexture(this._treeGeometryTexture!, tree.treeGeometry, tree.texWidth); + + this._treeTexWidth = tree.texWidth; + this._treeNodeCount = tree.nodeCount; + } + + private _getEdgeMidpoints(): { x: number; y: number }[] { + const midpoints: { x: number; y: number }[] = []; + for (let i = 0; i < this._edges.length; i++) { + const edge = this._edges[i]; + const srcId = typeof edge.source === 'object' ? (edge.source as ISimulationNode).id : (edge.source as number); + const tgtId = typeof edge.target === 'object' ? (edge.target as ISimulationNode).id : (edge.target as number); + const srcIdx = this._nodeIndexByNodeId[srcId]; + const tgtIdx = this._nodeIndexByNodeId[tgtId]; + if (srcIdx === undefined || tgtIdx === undefined) { + continue; + } + const src = this._nodes[srcIdx]; + const tgt = this._nodes[tgtIdx]; + midpoints.push({ + x: ((src.x ?? 0) + (tgt.x ?? 0)) * 0.5, + y: ((src.y ?? 0) + (tgt.y ?? 0)) * 0.5, + }); + } + return midpoints; + } + + private _buildAndUploadAdjacency(): void { + const N = this._nodes.length; + if (N === 0) { + return; + } + + const linkDist = this._settings.links?.distance ?? 50; + const adj = buildAdjacency(this._nodes, this._edges, linkDist, undefined); + this._cachedAdjacency = adj; + + this._uploadTexture(this._adjOffsetsTexture!, adj.offsets, adj.offsetsTexWidth); + this._uploadTexture(this._adjEdgesTexture!, adj.edges, adj.edgesTexWidth); + } + + private _pinNodes(nodes?: ISimulationNode[]) { + if (!nodes) { + nodes = this._nodes; + } + for (let i = 0; i < nodes.length; i++) { + this._pinNode(this._nodes[i]); + } + } + + private _unpinNodes(nodes?: ISimulationNode[]) { + if (!nodes) { + nodes = this._nodes; + } + for (let i = 0; i < nodes.length; i++) { + this._unpinNode(this._nodes[i]); + } + } + + private _pinNode(node: ISimulationNode) { + if (node.sx === null || node.sx === undefined) { + node.fx = node.x; + } + if (node.sy === null || node.sy === undefined) { + node.fy = node.y; + } + } + + private _unpinNode(node: ISimulationNode) { + if (node.sx === null || node.sx === undefined) { + node.fx = null; + } + if (node.sy === null || node.sy === undefined) { + node.fy = null; + } + } + + private _stickNode(node: ISimulationNode) { + node.sx = node.x; + node.fx = node.x; + node.sy = node.y; + node.fy = node.y; + } + + private _unstickNode(node: ISimulationNode) { + node.sx = null; + node.sy = null; + + if (this._settings.isPhysicsEnabled) { + node.fx = null; + node.fy = null; + } + } + + private _initializeNewData(data: Partial) { + if (data.nodes) { + for (let i = 0; i < data.nodes.length; i += 1) { + const nodeId = data.nodes[i].id; + + if (this._nodeIndexByNodeId[nodeId] !== undefined) { + this._nodeIndexByNodeId[nodeId] = i; + } else { + this._nodes.push(data.nodes[i]); + } + } + } else { + this._nodes = []; + } + + if (data.edges) { + const edgeIds: { [id: number]: number } = {}; + for (let i = 0; i < this._edges.length; i++) { + edgeIds[this._edges[i].id] = i; + } + for (let i = 0; i < data.edges.length; i++) { + const edgeId = data.edges[i].id; + if (edgeId in edgeIds) { + this._edges[edgeIds[edgeId]] = data.edges[i]; + } else { + this._edges.push(data.edges[i]); + } + } + } else { + this._edges = []; + } + + this._rebuildNodeIndex(); + this._cachedAdjacency = null; + } + + private _initGPU(): void { + const gl = this._gl; + + gl.getExtension('EXT_color_buffer_float'); + + const vs = compileShader(gl, forceVertSource, ShaderType.VERTEX); + const fs = compileShader(gl, forceFragSource, ShaderType.FRAGMENT); + const program = gl.createProgram(); + if (!program) { + throw new OrbError('Failed to create program.'); + } + this._forceProgram = program; + + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program); + throw new OrbError(`Failed to link force program: ${info}`); + } + + this._cacheUniformLocations(program); + + this._quadBuffer = gl.createBuffer(); + const quadVerts = new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]); + gl.bindBuffer(gl.ARRAY_BUFFER, this._quadBuffer); + gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW); + + this._quadVAO = gl.createVertexArray(); + gl.bindVertexArray(this._quadVAO); + const posLoc = gl.getAttribLocation(program, 'aPosition'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + + this._stateTexA = gl.createTexture(); + this._stateTexB = gl.createTexture(); + this._fixedTex = gl.createTexture(); + this._treeDataTexture = gl.createTexture(); + this._treeChildrenTexture = gl.createTexture(); + this._treeGeometryTexture = gl.createTexture(); + this._adjOffsetsTexture = gl.createTexture(); + this._adjEdgesTexture = gl.createTexture(); + + this._fboA = gl.createFramebuffer(); + this._fboB = gl.createFramebuffer(); + } + + private _cacheUniformLocations(program: WebGLProgram): void { + const gl = this._gl; + const names = [ + 'uState', + 'uFixed', + 'uTreeData', + 'uTreeChildren', + 'uTreeGeometry', + 'uAdjOffsets', + 'uAdjEdges', + 'uNodeCount', + 'uTexWidth', + 'uAlpha', + 'uDamping', + 'uManyBodyStrength', + 'uTheta2', + 'uDistanceMin2', + 'uDistanceMax2', + 'uTreeNodeCount', + 'uTreeTexWidth', + 'uAdjOffsetsTexWidth', + 'uAdjEdgesTexWidth', + 'uCenter', + 'uCenterStrength', + 'uCollisionRadius', + 'uCollisionStrength', + 'uForceXTarget', + 'uForceXStrength', + 'uForceYTarget', + 'uForceYStrength', + 'uHasManyBody', + 'uHasLinks', + 'uHasCentering', + 'uHasCollision', + 'uHasPositioning', + ]; + for (const name of names) { + this._uniforms[name] = gl.getUniformLocation(program, name); + } + } + + private _uploadDataToGPU(): void { + const gl = this._gl; + const N = this._nodes.length; + + this._texWidth = Math.max(1, Math.ceil(Math.sqrt(N))); + const texSize = this._texWidth * this._texWidth; + + const stateData = new Float32Array(texSize * 4); + const fixedData = new Float32Array(texSize * 4); + + for (let i = 0; i < N; i++) { + const node = this._nodes[i]; + const off = i * 4; + stateData[off] = node.x ?? 0; + stateData[off + 1] = node.y ?? 0; + stateData[off + 2] = node.vx ?? 0; + stateData[off + 3] = node.vy ?? 0; + + fixedData[off] = node.fx !== null && node.fx !== undefined ? 1.0 : 0.0; + fixedData[off + 1] = node.fx ?? node.x ?? 0; + fixedData[off + 2] = node.fy ?? node.y ?? 0; + fixedData[off + 3] = 0.0; + } + + this._uploadTexture(this._stateTexA!, stateData, this._texWidth); + this._uploadTexture(this._stateTexB!, stateData, this._texWidth); + this._uploadTexture(this._fixedTex!, fixedData, this._texWidth); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this._fboA); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._stateTexA, 0); + gl.bindFramebuffer(gl.FRAMEBUFFER, this._fboB); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._stateTexB, 0); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + this._pingPong = true; + } + + private _uploadTexture(texture: WebGLTexture, data: Float32Array, texWidth: number): void { + const gl = this._gl; + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, texWidth, texWidth, 0, gl.RGBA, gl.FLOAT, data); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + } + + private _simulateGPUStep(alpha: number): void { + const gl = this._gl; + const program = this._forceProgram; + if (!program) { + throw new OrbError('Force program not initialized.'); + } + const N = this._nodes.length; + if (N === 0) { + return; + } + + gl.useProgram(program); + + const u = this._uniforms; + + gl.uniform1i(u['uNodeCount'], N); + gl.uniform1i(u['uTexWidth'], this._texWidth); + gl.uniform1f(u['uAlpha'], alpha); + gl.uniform1f(u['uDamping'], 0.6); + + const hasManyBody = this._settings.manyBody !== null && this._settings.manyBody !== undefined; + gl.uniform1f(u['uHasManyBody'], hasManyBody ? 1.0 : 0.0); + if (hasManyBody) { + const mb = this._settings.manyBody!; + gl.uniform1f(u['uManyBodyStrength'], mb.strength); + const theta = mb.theta; + gl.uniform1f(u['uTheta2'], theta * theta); + gl.uniform1f(u['uDistanceMin2'], mb.distanceMin * mb.distanceMin); + const dMax = mb.distanceMax > 0 ? mb.distanceMax : getManyBodyMaxDistance(this._settings.links?.distance ?? 50); + gl.uniform1f(u['uDistanceMax2'], dMax * dMax); + gl.uniform1i(u['uTreeNodeCount'], this._treeNodeCount); + gl.uniform1i(u['uTreeTexWidth'], this._treeTexWidth); + } + + const hasLinks = this._cachedAdjacency !== null && this._edges.length > 0; + gl.uniform1f(u['uHasLinks'], hasLinks ? 1.0 : 0.0); + if (hasLinks) { + gl.uniform1i(u['uAdjOffsetsTexWidth'], this._cachedAdjacency!.offsetsTexWidth); + gl.uniform1i(u['uAdjEdgesTexWidth'], this._cachedAdjacency!.edgesTexWidth); + } + + gl.uniform1f(u['uHasCentering'], 0.0); + + const hasCollision = this._settings.collision !== null && this._settings.collision !== undefined; + gl.uniform1f(u['uHasCollision'], hasCollision ? 1.0 : 0.0); + if (hasCollision) { + gl.uniform1f(u['uCollisionRadius'], this._settings.collision!.radius); + gl.uniform1f(u['uCollisionStrength'], this._settings.collision!.strength); + } + + const hasPositioning = this._settings.positioning !== null && this._settings.positioning !== undefined; + gl.uniform1f(u['uHasPositioning'], hasPositioning ? 1.0 : 0.0); + if (hasPositioning) { + const pos = this._settings.positioning!; + gl.uniform1f(u['uForceXTarget'], pos.forceX?.x ?? 0); + gl.uniform1f(u['uForceXStrength'], pos.forceX?.strength ?? 0); + gl.uniform1f(u['uForceYTarget'], pos.forceY?.y ?? 0); + gl.uniform1f(u['uForceYStrength'], pos.forceY?.strength ?? 0); + } + + const readStateTex = this._pingPong ? this._stateTexA : this._stateTexB; + const writeFBO = this._pingPong ? this._fboB : this._fboA; + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, readStateTex); + gl.uniform1i(u['uState'], 0); + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this._fixedTex); + gl.uniform1i(u['uFixed'], 1); + + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this._treeDataTexture); + gl.uniform1i(u['uTreeData'], 2); + + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this._treeChildrenTexture); + gl.uniform1i(u['uTreeChildren'], 3); + + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this._adjOffsetsTexture); + gl.uniform1i(u['uAdjOffsets'], 4); + + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this._adjEdgesTexture); + gl.uniform1i(u['uAdjEdges'], 5); + + gl.activeTexture(gl.TEXTURE6); + gl.bindTexture(gl.TEXTURE_2D, this._treeGeometryTexture); + gl.uniform1i(u['uTreeGeometry'], 6); + + gl.bindFramebuffer(gl.FRAMEBUFFER, writeFBO); + gl.viewport(0, 0, this._texWidth, this._texWidth); + + gl.bindVertexArray(this._quadVAO); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + gl.bindVertexArray(null); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + this._pingPong = !this._pingPong; + } + + private _readbackFromGPU(): void { + const gl = this._gl; + const N = this._nodes.length; + if (N === 0) { + return; + } + + const readFBO = this._pingPong ? this._fboA : this._fboB; + const texSize = this._texWidth * this._texWidth; + const data = new Float32Array(texSize * 4); + + gl.bindFramebuffer(gl.FRAMEBUFFER, readFBO); + gl.readPixels(0, 0, this._texWidth, this._texWidth, gl.RGBA, gl.FLOAT, data); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + for (let i = 0; i < N; i++) { + const node = this._nodes[i]; + const off = i * 4; + node.x = data[off]; + node.y = data[off + 1]; + node.vx = data[off + 2]; + node.vy = data[off + 3]; + } + } +} diff --git a/src/simulator/engine/factory.ts b/src/simulator/engine/factory.ts index aa1e058..2897d64 100644 --- a/src/simulator/engine/factory.ts +++ b/src/simulator/engine/factory.ts @@ -7,6 +7,7 @@ import { IForceLayoutOptions, } from './shared'; import { ForceLayoutEngine } from './engines/dynamic/force-layout-engine'; +import { GPUForceLayoutEngine } from './engines/dynamic/gpu-force-layout-engine'; import { CircularLayoutEngine } from './engines/static/circular-layout-engine'; import { GridLayoutEngine } from './engines/static/grid-layout-engine'; import { HierarchicalLayoutEngine } from './engines/static/hierarchical-layout-engine'; @@ -22,8 +23,18 @@ export class LayoutEngineFactory { case 'hierarchical': return new HierarchicalLayoutEngine(settings.options as IHierarchicalLayoutOptions); case 'force': - default: - return new ForceLayoutEngine(settings?.options as IForceLayoutOptions); + default: { + const forceOptions = settings?.options as IForceLayoutOptions | undefined; + if (forceOptions?.useGPU) { + try { + return new GPUForceLayoutEngine(forceOptions); + } catch { + console.warn('WebGL2 unavailable, falling back to CPU force layout engine.'); + return new ForceLayoutEngine(forceOptions); + } + } + return new ForceLayoutEngine(forceOptions); + } } } } diff --git a/src/simulator/engine/shaders/force/force.frag b/src/simulator/engine/shaders/force/force.frag new file mode 100644 index 0000000..437d471 --- /dev/null +++ b/src/simulator/engine/shaders/force/force.frag @@ -0,0 +1,215 @@ +#version 300 es + +precision highp float; + +uniform sampler2D uState; +uniform sampler2D uFixed; +uniform sampler2D uTreeData; +uniform sampler2D uTreeChildren; +uniform sampler2D uTreeGeometry; +uniform sampler2D uAdjOffsets; +uniform sampler2D uAdjEdges; + +uniform int uNodeCount; +uniform int uTexWidth; +uniform float uAlpha; +uniform float uDamping; + +uniform float uManyBodyStrength; +uniform float uTheta2; +uniform float uDistanceMin2; +uniform float uDistanceMax2; +uniform int uTreeNodeCount; +uniform int uTreeTexWidth; + +uniform int uAdjOffsetsTexWidth; +uniform int uAdjEdgesTexWidth; + +uniform vec2 uCenter; +uniform float uCenterStrength; + +uniform float uCollisionRadius; +uniform float uCollisionStrength; + +uniform float uForceXTarget; +uniform float uForceXStrength; +uniform float uForceYTarget; +uniform float uForceYStrength; + +uniform float uHasManyBody; +uniform float uHasLinks; +uniform float uHasCentering; +uniform float uHasCollision; +uniform float uHasPositioning; + +out vec4 fragColor; + +ivec2 texCoord(int idx, int tw) { + return ivec2(idx % tw, idx / tw); +} + +void main() { + ivec2 fc = ivec2(gl_FragCoord.xy); + int nodeId = fc.y * uTexWidth + fc.x; + + if (nodeId >= uNodeCount) { + fragColor = vec4(0.0); + return; + } + + vec4 fixedData = texelFetch(uFixed, fc, 0); + if (fixedData.x > 0.5) { + fragColor = vec4(fixedData.yz, 0.0, 0.0); + return; + } + + vec4 state = texelFetch(uState, fc, 0); + vec2 pos = state.xy; + vec2 vel = state.zw; + + // === ManyBody (Barnes-Hut repulsion) === + if (uHasManyBody > 0.5 && uTreeNodeCount > 0) { + int stack[128]; + int top = 0; + stack[top++] = 0; + + while (top > 0) { + int idx = stack[--top]; + vec4 data = texelFetch(uTreeData, texCoord(idx, uTreeTexWidth), 0); + float w = data.w; + + if (w < -0.5) { + // Leaf node + int bodyIdx = int(-w - 0.5); + if (bodyIdx != nodeId) { + vec2 delta = data.xy - pos; + float distSq = dot(delta, delta); + + if (distSq < 1e-8) { + delta = vec2(float(nodeId) * 1e-4 - float(bodyIdx) * 1e-4 + 1e-4, 1e-4); + distSq = dot(delta, delta); + } + + if (distSq < uDistanceMax2) { + float l = distSq; + if (l < uDistanceMin2) l = sqrt(uDistanceMin2 * l); + vel += delta * (data.z * uAlpha / max(l, 1e-6)); + } + } + } else { + // Internal node - BH approximation check + vec2 delta = data.xy - pos; + float distSq = dot(delta, delta); + + if (distSq > 0.0 && w * w / distSq < uTheta2) { + if (distSq < uDistanceMax2) { + float l = distSq; + if (l < uDistanceMin2) l = sqrt(uDistanceMin2 * l); + vel += delta * (data.z * uAlpha / max(l, 1e-6)); + } + } else { + vec4 ch = texelFetch(uTreeChildren, texCoord(idx, uTreeTexWidth), 0); + if (ch.w >= 0.0 && top < 64) stack[top++] = int(ch.w + 0.5); + if (ch.z >= 0.0 && top < 64) stack[top++] = int(ch.z + 0.5); + if (ch.y >= 0.0 && top < 64) stack[top++] = int(ch.y + 0.5); + if (ch.x >= 0.0 && top < 64) stack[top++] = int(ch.x + 0.5); + } + } + } + } + + // === Collision (separate tree traversal - always descends to nearby leaves) === + if (uHasCollision > 0.5 && uCollisionRadius > 0.0 && uTreeNodeCount > 0) { + float collisionDiam = uCollisionRadius * 2.0; + // Use input-state predicted position (not accumulated vel) for symmetry + // with the quadtree, which stores positions from the same input state. + vec2 predictedPos = state.xy + state.zw; + int stack[64]; + int top = 0; + stack[top++] = 0; + + while (top > 0) { + int idx = stack[--top]; + vec4 data = texelFetch(uTreeData, texCoord(idx, uTreeTexWidth), 0); + float w = data.w; + + if (w < -0.5) { + // Leaf node - apply collision using predicted positions + int bodyIdx = int(-w - 0.5); + if (bodyIdx != nodeId && bodyIdx < uNodeCount) { + vec2 delta = data.xy - predictedPos; + float dist = length(delta); + + if (dist < collisionDiam && dist > 0.0) { + // d3's forceCollide: push = overlap * strength, split equally (bias=0.5) + float push = (collisionDiam - dist) * uCollisionStrength; + vel -= (delta / dist) * push * 0.5; + } + } + } else { + // Internal node - prune using geometric AABB distance. + vec4 geo = texelFetch(uTreeGeometry, texCoord(idx, uTreeTexWidth), 0); + float cellSize = geo.z; + // Compute distance from predicted pos to nearest point on the cell AABB + vec2 nearest = clamp(predictedPos, geo.xy, geo.xy + cellSize); + float distToCell = length(nearest - predictedPos); + + if (distToCell < collisionDiam) { + vec4 ch = texelFetch(uTreeChildren, texCoord(idx, uTreeTexWidth), 0); + if (ch.w >= 0.0 && top < 64) stack[top++] = int(ch.w + 0.5); + if (ch.z >= 0.0 && top < 64) stack[top++] = int(ch.z + 0.5); + if (ch.y >= 0.0 && top < 64) stack[top++] = int(ch.y + 0.5); + if (ch.x >= 0.0 && top < 64) stack[top++] = int(ch.x + 0.5); + } + } + } + } + + // === Link (spring) force === + if (uHasLinks > 0.5) { + vec4 offData = texelFetch(uAdjOffsets, texCoord(nodeId, uAdjOffsetsTexWidth), 0); + int start = int(offData.x + 0.5); + int count = int(offData.y + 0.5); + + for (int e = 0; e < count; e++) { + vec4 edgeData = texelFetch(uAdjEdges, texCoord(start + e, uAdjEdgesTexWidth), 0); + int targetId = int(edgeData.x + 0.5); + float restDist = edgeData.y; + float strength = edgeData.z; + float dirBias = edgeData.w; + + vec4 targetState = texelFetch(uState, texCoord(targetId, uTexWidth), 0); + // Use input-state predicted positions for BOTH sides to keep the link force + // symmetric in parallel execution. Using (pos + vel) here would include + // manyBody/collision forces already accumulated in vel for the current node + // but not for the target (read from input texture), creating a systematic + // inward bias that causes clumping at sustained alpha during drag. + vec2 delta = (targetState.xy + targetState.zw) - (state.xy + state.zw); + float d = length(delta); + + if (d < 1e-6) { + delta = vec2(1e-3, 1e-3); + d = length(delta); + } + + float scale = (d - restDist) / d * uAlpha * strength; + vel += delta * scale * dirBias; + } + } + + // === Centering force === + if (uHasCentering > 0.5) { + vel += (uCenter - pos) * uCenterStrength * uAlpha; + } + + // === Positioning force === + if (uHasPositioning > 0.5) { + vel.x += (uForceXTarget - pos.x) * uForceXStrength * uAlpha; + vel.y += (uForceYTarget - pos.y) * uForceYStrength * uAlpha; + } + + vel *= uDamping; + pos += vel; + + fragColor = vec4(pos, vel); +} diff --git a/src/simulator/engine/shaders/force/force.vert b/src/simulator/engine/shaders/force/force.vert new file mode 100644 index 0000000..63bfe79 --- /dev/null +++ b/src/simulator/engine/shaders/force/force.vert @@ -0,0 +1,7 @@ +#version 300 es + +in vec2 aPosition; + +void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); +} diff --git a/src/simulator/engine/shared.ts b/src/simulator/engine/shared.ts index 4bf5ac1..477d836 100644 --- a/src/simulator/engine/shared.ts +++ b/src/simulator/engine/shared.ts @@ -23,6 +23,7 @@ export const DEFAULT_CIRCULAR_LAYOUT_OPTIONS: ICircularLayoutOptions = { }; export interface IForceLayoutOptions extends ILayoutOptionsBase { + useGPU?: boolean; isSimulatingOnDataUpdate: boolean; isSimulatingOnSettingsUpdate: boolean; isSimulatingOnUnstick: boolean; @@ -44,6 +45,7 @@ export const getManyBodyMaxDistance = (linkDistance: number) => { }; export const DEFAULT_FORCE_LAYOUT_OPTIONS: IForceLayoutOptions = { + useGPU: false, isSimulatingOnDataUpdate: true, isSimulatingOnSettingsUpdate: true, isSimulatingOnUnstick: true, @@ -72,7 +74,7 @@ export const DEFAULT_FORCE_LAYOUT_OPTIONS: IForceLayoutOptions = { manyBody: { strength: -100, theta: 0.9, - distanceMin: 0, + distanceMin: 1, distanceMax: getManyBodyMaxDistance(DEFAULT_LINK_DISTANCE), }, positioning: { @@ -159,6 +161,7 @@ export interface IForceLayoutManyBody { theta: number; distanceMin: number; distanceMax: number; + edgeMidpointRepulsion?: boolean; } export interface IForceLayoutPositioning { diff --git a/src/simulator/engine/utils/adjacency-builder.ts b/src/simulator/engine/utils/adjacency-builder.ts new file mode 100644 index 0000000..3ddfba8 --- /dev/null +++ b/src/simulator/engine/utils/adjacency-builder.ts @@ -0,0 +1,105 @@ +import { ISimulationEdge, ISimulationNode } from '../../shared'; + +export interface IAdjacencyResult { + offsets: Float32Array; + edges: Float32Array; + offsetsTexWidth: number; + edgesTexWidth: number; +} + +export function buildAdjacency( + nodes: ISimulationNode[], + edges: ISimulationEdge[], + linkDistance: number, + linkStrength: number | undefined, +): IAdjacencyResult { + const N = nodes.length; + const nodeIndexById: Record = {}; + for (let i = 0; i < N; i++) { + nodeIndexById[nodes[i].id] = i; + } + + const degree = new Uint32Array(N); + + const resolvedEdges: { srcIdx: number; tgtIdx: number }[] = []; + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const srcId = typeof edge.source === 'object' ? (edge.source as ISimulationNode).id : (edge.source as number); + const tgtId = typeof edge.target === 'object' ? (edge.target as ISimulationNode).id : (edge.target as number); + const srcIdx = nodeIndexById[srcId]; + const tgtIdx = nodeIndexById[tgtId]; + if (srcIdx === undefined || tgtIdx === undefined) { + continue; + } + resolvedEdges.push({ srcIdx, tgtIdx }); + degree[srcIdx]++; + degree[tgtIdx]++; + } + + const totalDirectedEdges = resolvedEdges.length * 2; + + const adjCounts = new Uint32Array(N); + const adjStarts = new Uint32Array(N); + + const tempCounts = new Uint32Array(N); + for (const { srcIdx, tgtIdx } of resolvedEdges) { + tempCounts[srcIdx]++; + tempCounts[tgtIdx]++; + } + + let offset = 0; + for (let i = 0; i < N; i++) { + adjStarts[i] = offset; + adjCounts[i] = tempCounts[i]; + offset += tempCounts[i]; + } + + const edgesData = new Float32Array(totalDirectedEdges * 4); + const writePos = new Uint32Array(N); + for (let i = 0; i < N; i++) { + writePos[i] = adjStarts[i]; + } + + for (const { srcIdx, tgtIdx } of resolvedEdges) { + const bias = degree[srcIdx] / (degree[srcIdx] + degree[tgtIdx]); + const str = linkStrength !== undefined ? linkStrength : 1 / Math.min(degree[srcIdx], degree[tgtIdx]); + + { + const off = writePos[srcIdx] * 4; + edgesData[off] = tgtIdx; + edgesData[off + 1] = linkDistance; + edgesData[off + 2] = str; + edgesData[off + 3] = 1 - bias; + writePos[srcIdx]++; + } + + { + const off = writePos[tgtIdx] * 4; + edgesData[off] = srcIdx; + edgesData[off + 1] = linkDistance; + edgesData[off + 2] = str; + edgesData[off + 3] = bias; + writePos[tgtIdx]++; + } + } + + const offsetsTexWidth = Math.max(1, Math.ceil(Math.sqrt(N))); + const offsetsTexSize = offsetsTexWidth * offsetsTexWidth; + const offsetsData = new Float32Array(offsetsTexSize * 4); + for (let i = 0; i < N; i++) { + offsetsData[i * 4] = adjStarts[i]; + offsetsData[i * 4 + 1] = adjCounts[i]; + } + + const edgesTexWidth = Math.max(1, Math.ceil(Math.sqrt(totalDirectedEdges))); + const edgesTexSize = edgesTexWidth * edgesTexWidth; + const paddedEdges = new Float32Array(edgesTexSize * 4); + paddedEdges.set(edgesData); + + return { + offsets: offsetsData, + edges: paddedEdges, + offsetsTexWidth, + edgesTexWidth, + }; +} diff --git a/src/simulator/engine/utils/quadtree-builder.ts b/src/simulator/engine/utils/quadtree-builder.ts new file mode 100644 index 0000000..9d104a9 --- /dev/null +++ b/src/simulator/engine/utils/quadtree-builder.ts @@ -0,0 +1,245 @@ +export interface IQuadTreeResult { + treeData: Float32Array; + treeChildren: Float32Array; + treeGeometry: Float32Array; + nodeCount: number; + texWidth: number; +} + +interface ITreeNode { + cx: number; + cy: number; + charge: number; + size: number; + bodyIndex: number; + children: (number | null)[]; +} + +export function buildQuadTree(positions: { x: number; y: number }[], strength: number): IQuadTreeResult { + const N = positions.length; + if (N === 0) { + return { + treeData: new Float32Array(0), + treeChildren: new Float32Array(0), + treeGeometry: new Float32Array(0), + nodeCount: 0, + texWidth: 1, + }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let i = 0; i < N; i++) { + const x = positions[i].x; + const y = positions[i].y; + if (x < minX) { + minX = x; + } + if (y < minY) { + minY = y; + } + if (x > maxX) { + maxX = x; + } + if (y > maxY) { + maxY = y; + } + } + + let size = Math.max(maxX - minX, maxY - minY); + if (size < 1e-6) { + size = 1; + } + size *= 1.01; + const cx = (minX + maxX) * 0.5; + const cy = (minY + maxY) * 0.5; + const halfSize = size * 0.5; + const rootX0 = cx - halfSize; + const rootY0 = cy - halfSize; + + const nodes: ITreeNode[] = []; + + function allocNode(sz: number): number { + const idx = nodes.length; + nodes.push({ + cx: 0, + cy: 0, + charge: 0, + size: sz, + bodyIndex: -1, + children: [null, null, null, null], + }); + return idx; + } + + const rootIdx = allocNode(size); + + const nodeX0: number[] = [rootX0]; + const nodeY0: number[] = [rootY0]; + + function getQuadrant(px: number, py: number, x0: number, y0: number, sz: number): number { + const midX = x0 + sz * 0.5; + const midY = y0 + sz * 0.5; + const right = px >= midX ? 1 : 0; + const bottom = py >= midY ? 1 : 0; + return bottom * 2 + right; + } + + function childBounds(q: number, x0: number, y0: number, sz: number): { cx0: number; cy0: number; csz: number } { + const half = sz * 0.5; + const cx0 = q & 1 ? x0 + half : x0; + const cy0 = q & 2 ? y0 + half : y0; + return { cx0, cy0, csz: half }; + } + + function insertBody(bodyIdx: number, bx: number, by: number): void { + let nodeIdx = rootIdx; + let x0 = rootX0; + let y0 = rootY0; + let sz = size; + + for (let depth = 0; depth < 50; depth++) { + const node = nodes[nodeIdx]; + + if ( + node.bodyIndex === -1 && + node.children[0] === null && + node.children[1] === null && + node.children[2] === null && + node.children[3] === null + ) { + node.bodyIndex = bodyIdx; + node.cx = bx; + node.cy = by; + node.charge = strength; + return; + } + + if (node.bodyIndex >= 0) { + const existingBody = node.bodyIndex; + const ex = node.cx; + const ey = node.cy; + node.bodyIndex = -1; + + const eq = getQuadrant(ex, ey, x0, y0, sz); + const { cx0: ecx0, cy0: ecy0, csz: ecsz } = childBounds(eq, x0, y0, sz); + const childIdx = allocNode(ecsz); + nodeX0[childIdx] = ecx0; + nodeY0[childIdx] = ecy0; + node.children[eq] = childIdx; + nodes[childIdx].bodyIndex = existingBody; + nodes[childIdx].cx = ex; + nodes[childIdx].cy = ey; + nodes[childIdx].charge = strength; + } + + const q = getQuadrant(bx, by, x0, y0, sz); + if (node.children[q] === null) { + const { cx0, cy0, csz } = childBounds(q, x0, y0, sz); + const childIdx = allocNode(csz); + nodeX0[childIdx] = cx0; + nodeY0[childIdx] = cy0; + node.children[q] = childIdx; + nodes[childIdx].bodyIndex = bodyIdx; + nodes[childIdx].cx = bx; + nodes[childIdx].cy = by; + nodes[childIdx].charge = strength; + return; + } + + const { cx0, cy0, csz } = childBounds(q, x0, y0, sz); + nodeIdx = node.children[q]!; + x0 = cx0; + y0 = cy0; + sz = csz; + } + } + + for (let i = 0; i < N; i++) { + insertBody(i, positions[i].x, positions[i].y); + } + + function computeAggregates(idx: number): void { + const node = nodes[idx]; + if (node.bodyIndex >= 0) { + return; + } + + let totalCharge = 0; + let wcx = 0; + let wcy = 0; + let totalWeight = 0; + + for (let q = 0; q < 4; q++) { + const childIdx = node.children[q]; + if (childIdx === null) { + continue; + } + computeAggregates(childIdx); + const child = nodes[childIdx]; + const w = Math.abs(child.charge); + totalCharge += child.charge; + wcx += child.cx * w; + wcy += child.cy * w; + totalWeight += w; + } + + if (totalWeight > 0) { + node.cx = wcx / totalWeight; + node.cy = wcy / totalWeight; + } + node.charge = totalCharge; + } + + computeAggregates(rootIdx); + + const treeNodeCount = nodes.length; + const texWidth = Math.ceil(Math.sqrt(treeNodeCount)); + const texSize = texWidth * texWidth; + + const treeData = new Float32Array(texSize * 4); + const treeChildren = new Float32Array(texSize * 4); + const treeGeometry = new Float32Array(texSize * 4); + + for (let i = 0; i < treeNodeCount; i++) { + const node = nodes[i]; + const off = i * 4; + + treeData[off] = node.cx; + treeData[off + 1] = node.cy; + treeData[off + 2] = node.charge; + + if (node.bodyIndex >= 0) { + treeData[off + 3] = -(node.bodyIndex + 1); + } else { + treeData[off + 3] = node.size; + } + + treeChildren[off] = node.children[0] !== null ? node.children[0] : -1; + treeChildren[off + 1] = node.children[1] !== null ? node.children[1] : -1; + treeChildren[off + 2] = node.children[2] !== null ? node.children[2] : -1; + treeChildren[off + 3] = node.children[3] !== null ? node.children[3] : -1; + + treeGeometry[off] = nodeX0[i] ?? 0; + treeGeometry[off + 1] = nodeY0[i] ?? 0; + treeGeometry[off + 2] = node.size; + treeGeometry[off + 3] = 0; + } + + for (let i = treeNodeCount; i < texSize; i++) { + const off = i * 4; + treeData[off + 3] = 0; + treeChildren[off] = -1; + treeChildren[off + 1] = -1; + treeChildren[off + 2] = -1; + treeChildren[off + 3] = -1; + treeGeometry[off] = 0; + treeGeometry[off + 1] = 0; + treeGeometry[off + 2] = 0; + treeGeometry[off + 3] = 0; + } + + return { treeData, treeChildren, treeGeometry, nodeCount: treeNodeCount, texWidth }; +} diff --git a/src/simulator/factory.ts b/src/simulator/factory.ts index 4470442..57ed25c 100644 --- a/src/simulator/factory.ts +++ b/src/simulator/factory.ts @@ -1,5 +1,5 @@ import { DeepPartial } from '../utils/type.utils'; -import { ILayoutSettings } from './engine/shared'; +import { IForceLayoutOptions, ILayoutSettings } from './engine/shared'; import { ISimulator } from './shared'; import { MainThreadSimulator } from './types/main-thread-simulator'; import { WebWorkerSimulator } from './types/web-worker-simulator/web-worker-simulator'; @@ -8,6 +8,13 @@ import { WebWorkerSimulator } from './types/web-worker-simulator/web-worker-simu export class SimulatorFactory { static getSimulator(settings?: DeepPartial): ISimulator { const layoutSettings: DeepPartial = { type: 'force', ...settings }; + + // GPU engine requires main thread (needs WebGL context, cannot run in Web Worker) + const forceOptions = layoutSettings.options as Partial | undefined; + if (layoutSettings.type === 'force' && forceOptions?.useGPU) { + return new MainThreadSimulator(layoutSettings); + } + try { if (typeof Worker !== 'undefined') { return new WebWorkerSimulator(layoutSettings); diff --git a/src/utils/program.utils.ts b/src/utils/program.utils.ts new file mode 100644 index 0000000..e4530d5 --- /dev/null +++ b/src/utils/program.utils.ts @@ -0,0 +1,31 @@ +import { OrbError } from '../exceptions'; +import { compileShader, ShaderType } from './shaders.utils'; + +export const createProgram = ( + gl: WebGL2RenderingContext, + vertexSource: string, + fragmentSource: string, +): WebGLProgram => { + const vertexShader = compileShader(gl, vertexSource, ShaderType.VERTEX); + const fragmentShader = compileShader(gl, fragmentSource, ShaderType.FRAGMENT); + + const program = gl.createProgram(); + if (!program) { + throw new OrbError('Failed to create program.'); + } + + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const info = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new OrbError(`Failed to link program: ${info}`); + } + + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + return program; +}; diff --git a/src/utils/shaders.utils.ts b/src/utils/shaders.utils.ts new file mode 100644 index 0000000..60d966c --- /dev/null +++ b/src/utils/shaders.utils.ts @@ -0,0 +1,24 @@ +import { OrbError } from '../exceptions'; + +export enum ShaderType { + VERTEX = 'vertex', + FRAGMENT = 'fragment', +} + +export const compileShader = (gl: WebGL2RenderingContext, source: string, type: ShaderType): WebGLShader => { + const shader = gl.createShader(type === ShaderType.VERTEX ? gl.VERTEX_SHADER : gl.FRAGMENT_SHADER); + if (!shader) { + throw new OrbError('Failed to create shader.'); + } + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const info = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new OrbError(`Failed to compile shader: ${info}`); + } + + return shader; +}; diff --git a/src/views/orb-map-view.ts b/src/views/orb-map-view.ts index 91b0f4f..b59401d 100644 --- a/src/views/orb-map-view.ts +++ b/src/views/orb-map-view.ts @@ -42,9 +42,12 @@ const getDefaultMapTile = () => { const DEFAULT_ZOOM_LEVEL = 2; +export type INodeSizeMode = 'fixed' | 'geographic'; + export interface IMapSettings { zoomLevel: number; tile: ILeafletMapTile; + nodeSizeMode: INodeSizeMode; } export interface IOrbMapViewSettings { @@ -100,6 +103,7 @@ export class OrbMapView implements IOr map: { zoomLevel: settings.map?.zoomLevel ?? DEFAULT_ZOOM_LEVEL, tile: settings.map?.tile ?? getDefaultMapTile(), + nodeSizeMode: settings.map?.nodeSizeMode ?? 'geographic', }, render: { type: RendererType.CANVAS, @@ -184,6 +188,15 @@ export class OrbMapView implements IOr this._settings.map.tile = settings.map.tile; this._handleTileChange(); } + + if (settings.map.nodeSizeMode && settings.map.nodeSizeMode !== this._settings.map.nodeSizeMode) { + this._settings.map.nodeSizeMode = settings.map.nodeSizeMode; + this._updateGraphPositions(); + const leafletPos = (this._leaflet as any)._mapPane._leaflet_pos; + const k = this._getStyleScale(); + this._renderer.transform = { ...leafletPos, k }; + this._renderer.render(this._graph); + } } if (settings.render) { @@ -220,8 +233,12 @@ export class OrbMapView implements IOr recenter(onRendered?: () => void) { const view = this._graph.getBoundingBox(); - const topRightCoordinate = this._leaflet.layerPointToLatLng([view.x, view.y]); - const bottomLeftCoordinate = this._leaflet.layerPointToLatLng([view.x + view.width, view.y + view.height]); + const k = this._getStyleScale(); + const topRightCoordinate = this._leaflet.layerPointToLatLng([view.x * k, view.y * k]); + const bottomLeftCoordinate = this._leaflet.layerPointToLatLng([ + (view.x + view.width) * k, + (view.y + view.height) * k, + ]); this._leaflet.fitBounds(L.latLngBounds(topRightCoordinate, bottomLeftCoordinate)); onRendered?.(); } @@ -266,13 +283,15 @@ export class OrbMapView implements IOr leaflet.on('zoom', (event) => { this._updateGraphPositions(); + const leafletPos = event.target._mapPane._leaflet_pos; + const k = this._getStyleScale(); + this._renderer.transform = { ...leafletPos, k }; this._renderer.render(this._graph); - const transform = { ...event.target._mapPane._leaflet_pos, k: event.target._zoom }; - this._events.emit(OrbEventType.TRANSFORM, { transform }); + this._events.emit(OrbEventType.TRANSFORM, { transform: { ...leafletPos, k } }); }); leaflet.on('mousemove', (event: ILeafletEvent) => { - const point: IPosition = { x: event.layerPoint.x, y: event.layerPoint.y }; + const point: IPosition = this._toSimulationPoint(event.layerPoint); const containerPoint: IPosition = { x: event.containerPoint.x, y: event.containerPoint.y }; const response = this._strategy.onMouseMove(this._graph, point); @@ -312,7 +331,7 @@ export class OrbMapView implements IOr // Leaflet doesn't have a valid type definition for click event // @ts-ignore leaflet.on('click contextmenu dblclick', (event: ILeafletEvent) => { - const point: IPosition = { x: event.layerPoint.x, y: event.layerPoint.y }; + const point: IPosition = this._toSimulationPoint(event.layerPoint); const containerPoint: IPosition = { x: event.containerPoint.x, y: event.containerPoint.y }; if (event.type === 'contextmenu') { @@ -425,16 +444,17 @@ export class OrbMapView implements IOr leaflet.on('moveend', (event) => { const leafletPos = event.target._mapPane._leaflet_pos; - this._renderer.transform = { ...leafletPos, k: 1 }; + const k = this._getStyleScale(); + this._renderer.transform = { ...leafletPos, k }; this._renderer.render(this._graph); }); leaflet.on('drag', (event) => { const leafletPos = event.target._mapPane._leaflet_pos; - this._renderer.transform = { ...leafletPos, k: 1 }; + const k = this._getStyleScale(); + this._renderer.transform = { ...leafletPos, k }; this._renderer.render(this._graph); - const transform = { ...leafletPos, k: event.target._zoom }; - this._events.emit(OrbEventType.TRANSFORM, { transform }); + this._events.emit(OrbEventType.TRANSFORM, { transform: { ...leafletPos, k } }); }); return leaflet; @@ -442,6 +462,7 @@ export class OrbMapView implements IOr private _updateGraphPositions() { const nodes = this._graph.getNodes(); + const k = this._getStyleScale(); for (let i = 0; i < nodes.length; i++) { const coordinates = this._settings.getGeoPosition(nodes[i]); @@ -453,10 +474,22 @@ export class OrbMapView implements IOr } const layerPoint = this._leaflet.latLngToLayerPoint([coordinates.lat, coordinates.lng]); - nodes[i].setPosition(layerPoint, { isNotifySkipped: true }); + nodes[i].setPosition({ x: layerPoint.x / k, y: layerPoint.y / k }, { isNotifySkipped: true }); } } + private _getStyleScale(): number { + if (this._settings.map.nodeSizeMode === 'fixed') { + return 1; + } + return Math.pow(2, this._leaflet.getZoom() - this._settings.map.zoomLevel); + } + + private _toSimulationPoint(layerPoint: { x: number; y: number }): IPosition { + const k = this._getStyleScale(); + return { x: layerPoint.x / k, y: layerPoint.y / k }; + } + private _handleTileChange() { const newTile: ILeafletMapTile = this._settings.map.tile; diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 532d38e..d5af9a4 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -61,7 +61,8 @@ export class OrbView implements IOrbVi private _interaction: IGraphInteraction; private readonly _renderer: IRenderer; - private readonly _simulator: ISimulator; + private _simulator: ISimulator; + private _simulatorUsesGPU = false; private _simulationStartedAt = Date.now(); private _d3Zoom: ZoomBehavior; @@ -157,6 +158,7 @@ export class OrbView implements IOrbVi .on('dblclick.zoom', this.mouseDoubleClicked); this._simulator = SimulatorFactory.getSimulator(this._settings.layout); + this._simulatorUsesGPU = OrbView._needsGPU(this._settings.layout); this._initializeSimulationEvents(); this._graph.setSettings({ @@ -217,6 +219,16 @@ export class OrbView implements IOrbVi ...settings.layout, }; + const needsGPU = OrbView._needsGPU(this._settings.layout); + if (needsGPU !== this._simulatorUsesGPU) { + this._simulator.terminate(); + this._simulator = SimulatorFactory.getSimulator(this._settings.layout); + this._simulatorUsesGPU = needsGPU; + this._initializeSimulationEvents(); + } else { + this._simulator.setSettings(this._settings.layout); + } + const nodePositions = this._graph.getNodePositions(); const edgePositions = this._graph.getEdgePositions(); @@ -227,7 +239,6 @@ export class OrbView implements IOrbVi } } - this._simulator.setSettings(this._settings.layout); this._simulator.releaseNodes(); if (shouldRecenter) { @@ -267,6 +278,10 @@ export class OrbView implements IOrbVi } } + private static _needsGPU(layout: Partial): boolean { + return layout.type === 'force' && !!(layout.options as any)?.useGPU; + } + private _assignPositions = (nodes: INode[]) => { if (this._settings.getPosition) { for (let i = 0; i < nodes.length; i++) { @@ -621,22 +636,28 @@ export class OrbView implements IOrbVi this._simulationStartedAt = Date.now(); this._events.emit(OrbEventType.SIMULATION_START, undefined); }); + + const invalidate = () => (this._renderer as any).invalidateBuffers?.(); this._simulator.on(SimulatorEventType.SIMULATION_PROGRESS, (data) => { this._graph.setNodePositions(data.nodes); + invalidate(); this._events.emit(OrbEventType.SIMULATION_STEP, { progress: data.progress }); this.render(); }); this._simulator.on(SimulatorEventType.SIMULATION_END, (data) => { this._graph.setNodePositions(data.nodes); + invalidate(); this.render(); this._events.emit(OrbEventType.SIMULATION_END, { durationMs: Date.now() - this._simulationStartedAt }); }); this._simulator.on(SimulatorEventType.SIMULATION_STEP, (data) => { this._graph.setNodePositions(data.nodes); + invalidate(); this.render(); }); this._simulator.on(SimulatorEventType.NODE_DRAG, (data) => { this._graph.setNodePositions(data.nodes); + invalidate(); this.render(); }); this._simulator.on(SimulatorEventType.SETTINGS_UPDATE, (data) => { diff --git a/webpack.config.js b/webpack.config.js index c09811a..94f4eb5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,10 +11,14 @@ const commonConfiguration = { use: 'ts-loader', exclude: '/node_modules/', }, + { + test: /\.(glsl|vert|frag)$/, + type: 'asset/source', + }, ], }, resolve: { - extensions: ['.tsx', '.ts', '.js'], + extensions: ['.tsx', '.ts', '.js', '.glsl', '.vert', '.frag'], }, output: { chunkFilename(pathData) {