From db80e661bc24595e94b66fb8d6aa4ce25436c56f Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Thu, 26 Feb 2026 12:19:48 +0100 Subject: [PATCH 1/5] New: Add WebGL renderer --- src/glsl.d.ts | 14 + src/renderer/webgl/shaders/edge/edge.frag | 37 ++ src/renderer/webgl/shaders/edge/edge.vert | 55 +++ src/renderer/webgl/shaders/node/node.frag | 40 ++ src/renderer/webgl/shaders/node/node.vert | 52 +++ src/renderer/webgl/utils/color.utils.ts | 0 src/renderer/webgl/utils/program.utils.ts | 31 ++ src/renderer/webgl/utils/shaders.utils.ts | 24 ++ src/renderer/webgl/webgl-renderer.ts | 462 +++++++++++++++++++++- webpack.config.js | 6 +- 10 files changed, 702 insertions(+), 19 deletions(-) create mode 100644 src/glsl.d.ts create mode 100644 src/renderer/webgl/shaders/edge/edge.frag create mode 100644 src/renderer/webgl/shaders/edge/edge.vert create mode 100644 src/renderer/webgl/shaders/node/node.frag create mode 100644 src/renderer/webgl/shaders/node/node.vert create mode 100644 src/renderer/webgl/utils/color.utils.ts create mode 100644 src/renderer/webgl/utils/program.utils.ts create mode 100644 src/renderer/webgl/utils/shaders.utils.ts 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..985fe5f --- /dev/null +++ b/src/renderer/webgl/shaders/edge/edge.frag @@ -0,0 +1,37 @@ +#version 300 es + +precision highp float; + +in vec2 vLocalPos; +in vec4 vColor; +in vec4 vShadowColor; +in float vEdgeRatio; +in float vShadowOffsetPerp; +in float vShadowBlur; + +out vec4 fragColor; + +void main() { + float perpDist = abs(vLocalPos.y); + float shadowPerpDist = abs(vLocalPos.y - vShadowOffsetPerp); + + float shadowAlpha = 0.0; + if (vShadowBlur > 0.0) { + float t = max(shadowPerpDist - vEdgeRatio, 0.0) / vShadowBlur; + shadowAlpha = exp(-t * t * 1.5) * 0.5 * vShadowColor.a; + } + + float aa = 0.02 * vEdgeRatio; + float edgeAlpha = 1.0 - smoothstep(vEdgeRatio - aa, vEdgeRatio, perpDist); + vec4 edgeColor = vColor; + edgeColor.a *= edgeAlpha; + + float finalAlpha = edgeColor.a + shadowAlpha * (1.0 - edgeColor.a); + + if (finalAlpha < 0.001) { + discard; + } + + vec3 finalRGB = (edgeColor.rgb * edgeColor.a + vShadowColor.rgb * shadowAlpha * (1.0 - edgeColor.a)) / finalAlpha; + fragColor = vec4(finalRGB, finalAlpha); +} diff --git a/src/renderer/webgl/shaders/edge/edge.vert b/src/renderer/webgl/shaders/edge/edge.vert new file mode 100644 index 0000000..23385df --- /dev/null +++ b/src/renderer/webgl/shaders/edge/edge.vert @@ -0,0 +1,55 @@ +#version 300 es + +precision highp float; + +in vec2 aQuadPosition; + +in vec2 aStart; +in vec2 aEnd; +in float aWidth; +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 vLocalPos; +out vec4 vColor; +out vec4 vShadowColor; +out float vEdgeRatio; +out float vShadowOffsetPerp; +out float vShadowBlur; + +void main() { + vColor = aColor; + vShadowColor = aShadowColor; + vLocalPos = aQuadPosition; + + vec2 dir = aEnd - aStart; + float len = length(dir); + vec2 unitDir = dir / max(len, 0.0001); + vec2 perp = vec2(-unitDir.y, unitDir.x); + + float halfWidth = aWidth * 0.5; + + float shadowOffPerp = dot(vec2(aShadowOffsetX, aShadowOffsetY), perp); + + float totalHalfWidth = halfWidth + aShadowSize + abs(shadowOffPerp); + + vEdgeRatio = halfWidth / max(totalHalfWidth, 0.0001); + vShadowOffsetPerp = shadowOffPerp / max(totalHalfWidth, 0.0001); + vShadowBlur = aShadowSize / max(totalHalfWidth, 0.0001); + + vec2 midpoint = (aStart + aEnd) * 0.5; + vec2 worldPos = midpoint + unitDir * (len * 0.5) * aQuadPosition.x + perp * totalHalfWidth * aQuadPosition.y; + + 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/node/node.frag b/src/renderer/webgl/shaders/node/node.frag new file mode 100644 index 0000000..83e24eb --- /dev/null +++ b/src/renderer/webgl/shaders/node/node.frag @@ -0,0 +1,40 @@ +#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; + +out vec4 fragColor; + +void main() { + float dist = length(vUV); + float shadowDist = length(vUV - vShadowOffset); + + float shadowAlpha = 0.0; + if (vShadowBlur > 0.0) { + float t = max(shadowDist - vNodeRadius, 0.0) / vShadowBlur; + shadowAlpha = exp(-t * t * 1.5) * 0.5 * vShadowColor.a; + } + + float aa = 0.02 * vNodeRadius; + float nodeAlpha = 1.0 - smoothstep(vNodeRadius - aa, vNodeRadius, dist); + float borderMix = smoothstep(vBorderThreshold - aa, vBorderThreshold + aa, dist); + vec4 nodeColor = mix(vColor, vBorderColor, borderMix); + nodeColor.a *= nodeAlpha; + + float finalAlpha = nodeColor.a + shadowAlpha * (1.0 - nodeColor.a); + + if (finalAlpha < 0.001) { + discard; + } + + vec3 finalRGB = (nodeColor.rgb * nodeColor.a + vShadowColor.rgb * shadowAlpha * (1.0 - nodeColor.a)) / finalAlpha; + fragColor = vec4(finalRGB, finalAlpha); +} diff --git a/src/renderer/webgl/shaders/node/node.vert b/src/renderer/webgl/shaders/node/node.vert new file mode 100644 index 0000000..e7aa0cb --- /dev/null +++ b/src/renderer/webgl/shaders/node/node.vert @@ -0,0 +1,52 @@ +#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; + +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; + +void main() { + vColor = aColor; + vBorderColor = aBorderColor; + vShadowColor = aShadowColor; + + 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/program.utils.ts b/src/renderer/webgl/utils/program.utils.ts new file mode 100644 index 0000000..ebe2f62 --- /dev/null +++ b/src/renderer/webgl/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/renderer/webgl/utils/shaders.utils.ts b/src/renderer/webgl/utils/shaders.utils.ts new file mode 100644 index 0000000..64b29cf --- /dev/null +++ b/src/renderer/webgl/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/renderer/webgl/webgl-renderer.ts b/src/renderer/webgl/webgl-renderer.ts index 52ba3a1..8c80795 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 } 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, @@ -15,31 +15,61 @@ import { import { copyObject } from '../../utils/object.utils'; import { appendCanvas, setupContainer } from '../../utils/html.utils'; import { OrbError } from '../../exceptions'; +import { createProgram } from './utils/program.utils'; +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'; + +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 _nodeVao: WebGLVertexArrayObject | null = null; + private _edgeVao: WebGLVertexArrayObject | null = null; + + private _nodeInstanceBuffer: WebGLBuffer | null = null; + private _edgeInstanceBuffer: WebGLBuffer | 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; + 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 +78,220 @@ export class WebGLRenderer extends Emi ...settings, }; - console.log('context', this._context); + this._initShaders(); + this._initNodeBuffers(); + this._initEdgeBuffers(); + + console.log('context', this._gl); + } + + private _initShaders(): void { + this._nodeProgram = createProgram(this._gl, nodeVertexSource, nodeFragmentSource); + this._edgeProgram = createProgram(this._gl, edgeVertexSource, edgeFragmentSource); + } + + 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 = 19 * Float32Array.BYTES_PER_ELEMENT; + + const centerLoc = gl.getAttribLocation(this._nodeProgram, 'aCenter'); + gl.enableVertexAttribArray(centerLoc); + gl.vertexAttribPointer(centerLoc, 2, gl.FLOAT, false, INSTANCE_STRIDE, 0); + gl.vertexAttribDivisor(centerLoc, 1); + + const radiusLoc = gl.getAttribLocation(this._nodeProgram, 'aRadius'); + gl.enableVertexAttribArray(radiusLoc); + gl.vertexAttribPointer(radiusLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 2 * 4); + gl.vertexAttribDivisor(radiusLoc, 1); + + const colorLoc = gl.getAttribLocation(this._nodeProgram, 'aColor'); + gl.enableVertexAttribArray(colorLoc); + gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, INSTANCE_STRIDE, 3 * 4); + gl.vertexAttribDivisor(colorLoc, 1); + + const borderColorLoc = gl.getAttribLocation(this._nodeProgram, 'aBorderColor'); + gl.enableVertexAttribArray(borderColorLoc); + gl.vertexAttribPointer(borderColorLoc, 4, gl.FLOAT, false, INSTANCE_STRIDE, 7 * 4); + gl.vertexAttribDivisor(borderColorLoc, 1); + + const borderWidthLoc = gl.getAttribLocation(this._nodeProgram, 'aBorderWidth'); + gl.enableVertexAttribArray(borderWidthLoc); + gl.vertexAttribPointer(borderWidthLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 11 * 4); + gl.vertexAttribDivisor(borderWidthLoc, 1); + + const shadowColorLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowColor'); + gl.enableVertexAttribArray(shadowColorLoc); + gl.vertexAttribPointer(shadowColorLoc, 4, gl.FLOAT, false, INSTANCE_STRIDE, 12 * 4); + gl.vertexAttribDivisor(shadowColorLoc, 1); + + const shadowSizeLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowSize'); + gl.enableVertexAttribArray(shadowSizeLoc); + gl.vertexAttribPointer(shadowSizeLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 16 * 4); + gl.vertexAttribDivisor(shadowSizeLoc, 1); + + const shadowOffsetXLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowOffsetX'); + gl.enableVertexAttribArray(shadowOffsetXLoc); + gl.vertexAttribPointer(shadowOffsetXLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 17 * 4); + gl.vertexAttribDivisor(shadowOffsetXLoc, 1); + + const shadowOffsetYLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowOffsetY'); + gl.enableVertexAttribArray(shadowOffsetYLoc); + gl.vertexAttribPointer(shadowOffsetYLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 18 * 4); + gl.vertexAttribDivisor(shadowOffsetYLoc, 1); + + 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 = 16 * 4; + + const startLoc = gl.getAttribLocation(this._edgeProgram, 'aStart'); + gl.enableVertexAttribArray(startLoc); + gl.vertexAttribPointer(startLoc, 2, gl.FLOAT, false, STRIDE, 0); + gl.vertexAttribDivisor(startLoc, 1); + + const endLoc = gl.getAttribLocation(this._edgeProgram, 'aEnd'); + gl.enableVertexAttribArray(endLoc); + gl.vertexAttribPointer(endLoc, 2, gl.FLOAT, false, STRIDE, 2 * 4); + gl.vertexAttribDivisor(endLoc, 1); + + const widthLoc = gl.getAttribLocation(this._edgeProgram, 'aWidth'); + gl.enableVertexAttribArray(widthLoc); + gl.vertexAttribPointer(widthLoc, 1, gl.FLOAT, false, STRIDE, 4 * 4); + gl.vertexAttribDivisor(widthLoc, 1); + + const colorLoc = gl.getAttribLocation(this._edgeProgram, 'aColor'); + gl.enableVertexAttribArray(colorLoc); + gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, STRIDE, 5 * 4); + gl.vertexAttribDivisor(colorLoc, 1); + + const shadowColorLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowColor'); + gl.enableVertexAttribArray(shadowColorLoc); + gl.vertexAttribPointer(shadowColorLoc, 4, gl.FLOAT, false, STRIDE, 9 * 4); + gl.vertexAttribDivisor(shadowColorLoc, 1); + + const shadowSizeLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowSize'); + gl.enableVertexAttribArray(shadowSizeLoc); + gl.vertexAttribPointer(shadowSizeLoc, 1, gl.FLOAT, false, STRIDE, 13 * 4); + gl.vertexAttribDivisor(shadowSizeLoc, 1); + + const shadowOffsetXLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowOffsetX'); + gl.enableVertexAttribArray(shadowOffsetXLoc); + gl.vertexAttribPointer(shadowOffsetXLoc, 1, gl.FLOAT, false, STRIDE, 14 * 4); + gl.vertexAttribDivisor(shadowOffsetXLoc, 1); + + const shadowOffsetYLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowOffsetY'); + gl.enableVertexAttribArray(shadowOffsetYLoc); + gl.vertexAttribPointer(shadowOffsetYLoc, 1, gl.FLOAT, false, STRIDE, 15 * 4); + gl.vertexAttribDivisor(shadowOffsetYLoc, 1); + + 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 +311,7 @@ export class WebGLRenderer extends Emi } get isInitiallyRendered(): boolean { - throw new Error('Method not implemented.'); + return this._isInitiallyRendered; } getSettings(): IRendererSettings { @@ -83,34 +326,217 @@ export class WebGLRenderer extends Emi } render(graph: IGraph): void { - console.log('graph:', graph); - throw new Error('Method not implemented.'); + if (!this._nodeProgram || !this._edgeProgram) { + throw new OrbError('Node or edge program not initialized.'); + } + + const gl = this._gl; + + const rect = this._container.getBoundingClientRect(); + 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 = 16; + const edgeData = new Float32Array(edges.length * FLOATS_PER_EDGE); + + if (edges.length !== this._lastEdgeCount || this._isColorCacheDirty) { + this._buildEdgeColorCache(edges); + this._buildEdgeShadowColorCache(edges); + this._isColorCacheDirty = false; + this._lastEdgeCount = edges.length; + } + + for (let i = 0; i < edges.length; i++) { + const edge = edges[i]; + const start = edge.startNode.getCenter(); + const end = edge.endNode.getCenter(); + const shadowSize = edge.getStyle().shadowSize || 0; + const shadowOffsetX = edge.getStyle().shadowOffsetX || 0; + const shadowOffsetY = edge.getStyle().shadowOffsetY || 0; + const shadowColor = this._edgeShadowColorCache.get(edge.id) || [0, 0, 0, 0]; + const off = i * FLOATS_PER_EDGE; + + let width; + let rgba; + + if (edge.isHovered() || edge.isSelected()) { + width = edge.getWidth(); + rgba = this._resolveColor(edge.getColor()); + } else { + width = edge.getWidth(); + rgba = this._edgeColorCache.get(edge.id) || [0.6, 0.6, 0.6, 1]; + } + + edgeData[off] = start.x; + edgeData[off + 1] = start.y; + edgeData[off + 2] = end.x; + edgeData[off + 3] = end.y; + edgeData[off + 4] = width; + edgeData[off + 5] = rgba[0]; + edgeData[off + 6] = rgba[1]; + edgeData[off + 7] = rgba[2]; + edgeData[off + 8] = rgba[3]; + edgeData[off + 9] = shadowColor[0]; + edgeData[off + 10] = shadowColor[1]; + edgeData[off + 11] = shadowColor[2]; + edgeData[off + 12] = shadowColor[3]; + edgeData[off + 13] = shadowSize; + edgeData[off + 14] = shadowOffsetX; + edgeData[off + 15] = shadowOffsetY; + } + + gl.useProgram(this._edgeProgram); + this._setViewUniforms(this._edgeProgram); + gl.bindBuffer(gl.ARRAY_BUFFER, this._edgeInstanceBuffer); + gl.bufferData(gl.ARRAY_BUFFER, edgeData, gl.DYNAMIC_DRAW); + gl.bindVertexArray(this._edgeVao); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, edges.length); + gl.bindVertexArray(null); + + gl.useProgram(this._nodeProgram); + this._setViewUniforms(this._nodeProgram); + + const nodes = graph.getNodes(); + const FLOATS_PER_NODE = 19; + const instanceData = new Float32Array(nodes.length * FLOATS_PER_NODE); + + if (nodes.length !== this._lastNodeCount || this._isColorCacheDirty) { + this._buildNodeColorCache(nodes); + this._buildNodeBorderColorCache(nodes); + this._buildNodeShadowColorCache(nodes); + this._isColorCacheDirty = false; + this._lastNodeCount = nodes.length; + } + + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const center = node.getCenter(); + const radius = node.getRadius(); + const shadowSize = node.getStyle().shadowSize || 0; + const shadowOffsetX = node.getStyle().shadowOffsetX || 0; + const shadowOffsetY = node.getStyle().shadowOffsetY || 0; + const shadowColor = this._nodeShadowColorCache.get(node.id) || [0, 0, 0, 0]; + 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) || [1, 0, 0, 1]; + borderColor = this._nodeBorderColorCache.get(node.id) || [0, 0, 0, 0]; + 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; + } + + gl.bindBuffer(gl.ARRAY_BUFFER, this._nodeInstanceBuffer); + gl.bufferData(gl.ARRAY_BUFFER, instanceData, gl.DYNAMIC_DRAW); + + gl.bindVertexArray(this._nodeVao); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, nodes.length); + gl.bindVertexArray(null); + + this._isInitiallyRendered = true; } 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/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) { From 37da296425dfe5f001427adc5113987908d34768 Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Fri, 27 Feb 2026 08:21:01 +0100 Subject: [PATCH 2/5] New: Add naive WebGL force layout computation --- src/index.ts | 1 + src/renderer/webgl/webgl-renderer.ts | 2 +- src/simulator/engine/factory.ts | 15 + src/simulator/engine/shaders/copy/copy.frag | 10 + src/simulator/engine/shaders/copy/copy.vert | 18 + src/simulator/engine/shaders/force/force.frag | 5 + src/simulator/engine/shaders/force/force.vert | 62 ++ src/simulator/engine/shared.ts | 92 +++ .../engine/{ => types}/d3-simulator-engine.ts | 107 +-- .../engine/types/gpu-simulator-engine.ts | 677 ++++++++++++++++++ src/simulator/factory.ts | 10 +- src/simulator/index.ts | 2 + src/simulator/shared.ts | 2 +- src/simulator/types/main-thread-simulator.ts | 26 +- .../message/worker-input.ts | 2 +- .../message/worker-output.ts | 2 +- .../web-worker-simulator/simulator.worker.ts | 2 +- .../web-worker-simulator.ts | 2 +- .../webgl => }/utils/program.utils.ts | 2 +- .../webgl => }/utils/shaders.utils.ts | 2 +- src/views/orb-view.ts | 10 +- 21 files changed, 938 insertions(+), 113 deletions(-) create mode 100644 src/simulator/engine/factory.ts create mode 100644 src/simulator/engine/shaders/copy/copy.frag create mode 100644 src/simulator/engine/shaders/copy/copy.vert create mode 100644 src/simulator/engine/shaders/force/force.frag create mode 100644 src/simulator/engine/shaders/force/force.vert create mode 100644 src/simulator/engine/shared.ts rename src/simulator/engine/{ => types}/d3-simulator-engine.ts (88%) create mode 100644 src/simulator/engine/types/gpu-simulator-engine.ts rename src/{renderer/webgl => }/utils/program.utils.ts (94%) rename src/{renderer/webgl => }/utils/shaders.utils.ts (93%) diff --git a/src/index.ts b/src/index.ts index b81444b..638b17a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,3 +22,4 @@ export { IEdge, IEdgeBase, IEdgePosition, IEdgeStyle, isEdge, EdgeType } from '. export { IGraphStyle, getDefaultGraphStyle } from './models/style'; export { ICircle, IPosition, IRectangle, Color, IColorRGB } from './common'; export { OrbView, OrbMapView, IOrbView, IOrbMapViewSettings, IOrbViewSettings } from './views'; +export { SimulatorEngineType } from './simulator'; diff --git a/src/renderer/webgl/webgl-renderer.ts b/src/renderer/webgl/webgl-renderer.ts index 8c80795..aee03cf 100644 --- a/src/renderer/webgl/webgl-renderer.ts +++ b/src/renderer/webgl/webgl-renderer.ts @@ -15,7 +15,7 @@ import { import { copyObject } from '../../utils/object.utils'; import { appendCanvas, setupContainer } from '../../utils/html.utils'; import { OrbError } from '../../exceptions'; -import { createProgram } from './utils/program.utils'; +import { createProgram } from '../../utils/program.utils'; import nodeVertexSource from './shaders/node/node.vert'; import nodeFragmentSource from './shaders/node/node.frag'; import edgeVertexSource from './shaders/edge/edge.vert'; diff --git a/src/simulator/engine/factory.ts b/src/simulator/engine/factory.ts new file mode 100644 index 0000000..73acbea --- /dev/null +++ b/src/simulator/engine/factory.ts @@ -0,0 +1,15 @@ +import { ISimulatorEngine, SimulatorEngineType } from './shared'; +import { D3SimulatorEngine } from './types/d3-simulator-engine'; +import { GPUSimulatorEngine } from './types/gpu-simulator-engine'; + +export class SimulatorEngineFactory { + static getEngine(type: SimulatorEngineType = SimulatorEngineType.D3): ISimulatorEngine { + switch (type) { + case SimulatorEngineType.GPU: + return new GPUSimulatorEngine(); + case SimulatorEngineType.D3: + default: + return new D3SimulatorEngine(); + } + } +} diff --git a/src/simulator/engine/shaders/copy/copy.frag b/src/simulator/engine/shaders/copy/copy.frag new file mode 100644 index 0000000..ec3d076 --- /dev/null +++ b/src/simulator/engine/shaders/copy/copy.frag @@ -0,0 +1,10 @@ +#version 300 es + +precision highp float; + +in vec2 vPos; +out vec4 fragColor; + +void main() { + fragColor = vec4(vPos, 0.0, 0.0); +} diff --git a/src/simulator/engine/shaders/copy/copy.vert b/src/simulator/engine/shaders/copy/copy.vert new file mode 100644 index 0000000..50321d5 --- /dev/null +++ b/src/simulator/engine/shaders/copy/copy.vert @@ -0,0 +1,18 @@ +#version 300 es + +precision highp float; + +in vec2 aPosition; + +uniform int uTexWidth; + +out vec2 vPos; + +void main() { + int tx = gl_VertexID % uTexWidth; + int ty = gl_VertexID / uTexWidth; + vec2 ndc = (vec2(float(tx), float(ty)) + 0.5) / float(uTexWidth) * 2.0 - 1.0; + gl_Position = vec4(ndc, 0.0, 1.0); + gl_PointSize = 1.0; + vPos = aPosition; +} diff --git a/src/simulator/engine/shaders/force/force.frag b/src/simulator/engine/shaders/force/force.frag new file mode 100644 index 0000000..cdcb8f2 --- /dev/null +++ b/src/simulator/engine/shaders/force/force.frag @@ -0,0 +1,5 @@ +#version 300 es + +precision highp float; + +void main() {} \ No newline at end of file diff --git a/src/simulator/engine/shaders/force/force.vert b/src/simulator/engine/shaders/force/force.vert new file mode 100644 index 0000000..5c7c621 --- /dev/null +++ b/src/simulator/engine/shaders/force/force.vert @@ -0,0 +1,62 @@ +#version 300 es + +precision highp float; + +in vec2 aPosition; +in vec2 aVelocity; +in float aFixed; // 1.0 if node is fixed, 0.0 if free +in vec2 aFixedPos; + +uniform sampler2D uPositions; +uniform int uNodeCount; +uniform int uTexWidth; +uniform float uAlpha; +uniform float uRepulsionStrength; +uniform vec2 uCenter; +uniform float uCenterStrength; +uniform float uDamping; + +out vec2 vPosition; +out vec2 vVelocity; +out float vFixed; +out vec2 vFixedPos; + +ivec2 idx2uv(int idx) { + return ivec2(idx % uTexWidth, idx / uTexWidth); +} + +void main() { + int nodeId = gl_VertexID; + + vFixed = aFixed; + vFixedPos = aFixedPos; + + if (aFixed > 0.5) { + vPosition = aFixedPos; + vVelocity = vec2(0.0); + return; + } + + vec2 pos = aPosition; + vec2 vel = aVelocity; + + vec2 repulsion = vec2(0.0); + for (int j = 0; j < uNodeCount; j++) { + if (j == nodeId) { + continue; + } + + vec4 other = texelFetch(uPositions, idx2uv(j), 0); + vec2 delta = other.xy - pos; + float distSq = dot(delta, delta) + 1.0; + repulsion += delta * (uRepulsionStrength * uAlpha / distSq); + } + + vec2 centering = (uCenter - pos) * uCenterStrength * uAlpha; + + vel = (vel + repulsion + centering) * uDamping; + pos = pos + vel; + + vPosition = pos; + vVelocity = vel; +} \ No newline at end of file diff --git a/src/simulator/engine/shared.ts b/src/simulator/engine/shared.ts new file mode 100644 index 0000000..50b533c --- /dev/null +++ b/src/simulator/engine/shared.ts @@ -0,0 +1,92 @@ +import { IPosition } from '../../common'; +import { IEmitter } from '../../utils/emitter.utils'; +import { ISimulationGraph, ISimulationIds, ISimulationNode } from '../shared'; + +export enum SimulatorEngineEventType { + SIMULATION_START = 'simulation-start', + SIMULATION_STOP = 'simulation-stop', + SIMULATION_PROGRESS = 'simulation-progress', + SIMULATION_END = 'simulation-end', + SIMULATION_TICK = 'simulation-tick', + SIMULATION_RESET = 'simulation-reset', + NODE_DRAG = 'node-drag', + SETTINGS_UPDATE = 'settings-update', + DATA_CLEARED = 'data-cleared', +} + +export interface ISimulatorEngineProgress { + progress: number; +} + +export interface ISimulatorEngineNodeId { + id: number; +} + +export interface ISimulatorEngineSettings { + settings: ISimulatorEngineSettingsConfig; +} + +export interface ISimulatorEngineSettingsConfig { + isSimulatingOnDataUpdate: boolean; + isSimulatingOnSettingsUpdate: boolean; + isSimulatingOnUnstick: boolean; + isPhysicsEnabled: boolean; + alpha: { + alpha: number; + alphaMin: number; + alphaDecay: number; + alphaTarget: number; + }; + centering: { x: number; y: number; strength: number } | null; + collision: { radius: number; strength: number; iterations: number } | null; + links: { distance: number; strength?: number; iterations: number }; + manyBody: { strength: number; theta: number; distanceMin: number; distanceMax: number } | null; + positioning: { + forceX: { x: number; strength: number }; + forceY: { y: number; strength: number }; + } | null; +} + +export type ISimulatorEngineSettingsUpdate = Partial; + +export type SimulatorEngineEvents = { + [SimulatorEngineEventType.SIMULATION_START]: undefined; + [SimulatorEngineEventType.SIMULATION_PROGRESS]: ISimulationGraph & ISimulatorEngineProgress; + [SimulatorEngineEventType.SIMULATION_END]: ISimulationGraph; + [SimulatorEngineEventType.SIMULATION_TICK]: ISimulationGraph; + [SimulatorEngineEventType.SIMULATION_RESET]: ISimulationGraph; + [SimulatorEngineEventType.NODE_DRAG]: ISimulationGraph; + [SimulatorEngineEventType.SETTINGS_UPDATE]: ISimulatorEngineSettings; + [SimulatorEngineEventType.DATA_CLEARED]: ISimulationGraph; +}; + +export enum SimulatorEngineType { + D3 = 'd3', + GPU = 'gpu', +} + +export interface ISimulatorEngine extends IEmitter { + getSettings(): ISimulatorEngineSettingsConfig; + setSettings(settings: ISimulatorEngineSettingsUpdate): void; + resetSettings(): ISimulatorEngineSettingsConfig; + + setupData(data: ISimulationGraph): void; + mergeData(data: Partial): void; + updateData(data: ISimulationGraph): void; + deleteData(data: Partial): void; + patchData(data: Partial): void; + clearData(): void; + + activateSimulation(): void; + stopSimulation(): void; + resetSimulation(): void; + + startDragNode(): void; + dragNode(data: ISimulatorEngineNodeId & IPosition): void; + endDragNode(data: ISimulatorEngineNodeId): void; + + fixNodes(nodes?: ISimulationNode[]): void; + unfixNodes(nodes?: ISimulationNode[]): void; + stickNodes(nodes?: ISimulationNode[]): void; + unstickNodes(nodes?: ISimulationNode[]): void; +} diff --git a/src/simulator/engine/d3-simulator-engine.ts b/src/simulator/engine/types/d3-simulator-engine.ts similarity index 88% rename from src/simulator/engine/d3-simulator-engine.ts rename to src/simulator/engine/types/d3-simulator-engine.ts index f20346f..4aab063 100644 --- a/src/simulator/engine/d3-simulator-engine.ts +++ b/src/simulator/engine/types/d3-simulator-engine.ts @@ -10,83 +10,31 @@ import { Simulation, SimulationLinkDatum, } from 'd3-force'; -import { IPosition } from '../../common'; -import { ISimulationNode, ISimulationEdge, ISimulationGraph } from '../shared'; -import { Emitter } from '../../utils/emitter.utils'; -import { isObjectEqual, copyObject } from '../../utils/object.utils'; +import { IPosition } from '../../../common'; +import { ISimulationNode, ISimulationEdge, ISimulationGraph } from '../../shared'; +import { Emitter } from '../../../utils/emitter.utils'; +import { isObjectEqual, copyObject } from '../../../utils/object.utils'; +import { + ISimulatorEngine, + ISimulatorEngineSettingsConfig, + ISimulatorEngineSettingsUpdate, + SimulatorEngineEventType, + SimulatorEngineEvents, +} from '../shared'; const MANY_BODY_MAX_DISTANCE_TO_LINK_DISTANCE_RATIO = 100; const DEFAULT_LINK_DISTANCE = 50; -export enum D3SimulatorEngineEventType { - SIMULATION_START = 'simulation-start', - SIMULATION_STOP = 'simulation-stop', - SIMULATION_PROGRESS = 'simulation-progress', - SIMULATION_END = 'simulation-end', - SIMULATION_TICK = 'simulation-tick', - SIMULATION_RESET = 'simulation-reset', - NODE_DRAG = 'node-drag', - SETTINGS_UPDATE = 'settings-update', - DATA_CLEARED = 'data-cleared', -} - -export interface ID3SimulatorEngineSettingsAlpha { - alpha: number; - alphaMin: number; - alphaDecay: number; - alphaTarget: number; -} - -export interface ID3SimulatorEngineSettingsCentering { - x: number; - y: number; - strength: number; -} - -export interface ID3SimulatorEngineSettingsCollision { - radius: number; - strength: number; - iterations: number; -} - -export interface ID3SimulatorEngineSettingsLinks { - distance: number; - strength?: number; - iterations: number; -} - -export interface ID3SimulatorEngineSettingsManyBody { - strength: number; - theta: number; - distanceMin: number; - distanceMax: number; -} - -export interface ID3SimulatorEngineSettingsPositioning { - forceX: { - x: number; - strength: number; - }; - forceY: { - y: number; - strength: number; - }; -} - -export interface ID3SimulatorEngineSettings { - isSimulatingOnDataUpdate: boolean; - isSimulatingOnSettingsUpdate: boolean; - isSimulatingOnUnstick: boolean; - isPhysicsEnabled: boolean; - alpha: ID3SimulatorEngineSettingsAlpha; - centering: ID3SimulatorEngineSettingsCentering | null; - collision: ID3SimulatorEngineSettingsCollision | null; - links: ID3SimulatorEngineSettingsLinks; - manyBody: ID3SimulatorEngineSettingsManyBody | null; - positioning: ID3SimulatorEngineSettingsPositioning | null; -} - -export type ID3SimulatorEngineSettingsUpdate = Partial; +// Backward-compatible aliases +export const D3SimulatorEngineEventType = SimulatorEngineEventType; +export type ID3SimulatorEngineSettingsAlpha = ISimulatorEngineSettingsConfig['alpha']; +export type ID3SimulatorEngineSettingsCentering = NonNullable; +export type ID3SimulatorEngineSettingsCollision = NonNullable; +export type ID3SimulatorEngineSettingsLinks = ISimulatorEngineSettingsConfig['links']; +export type ID3SimulatorEngineSettingsManyBody = NonNullable; +export type ID3SimulatorEngineSettingsPositioning = NonNullable; +export type ID3SimulatorEngineSettings = ISimulatorEngineSettingsConfig; +export type ID3SimulatorEngineSettingsUpdate = ISimulatorEngineSettingsUpdate; export const getManyBodyMaxDistance = (linkDistance: number) => { const distance = linkDistance > 0 ? linkDistance : 1; @@ -153,18 +101,7 @@ interface IRunSimulationOptions { isUpdatingSettings: boolean; } -export type D3SimulatorEvents = { - [D3SimulatorEngineEventType.SIMULATION_START]: undefined; - [D3SimulatorEngineEventType.SIMULATION_PROGRESS]: ISimulationGraph & ID3SimulatorProgress; - [D3SimulatorEngineEventType.SIMULATION_END]: ISimulationGraph; - [D3SimulatorEngineEventType.SIMULATION_TICK]: ISimulationGraph; - [D3SimulatorEngineEventType.SIMULATION_RESET]: ISimulationGraph; - [D3SimulatorEngineEventType.NODE_DRAG]: ISimulationGraph; - [D3SimulatorEngineEventType.SETTINGS_UPDATE]: ID3SimulatorSettings; - [D3SimulatorEngineEventType.DATA_CLEARED]: ISimulationGraph; -}; - -export class D3SimulatorEngine extends Emitter { +export class D3SimulatorEngine extends Emitter implements ISimulatorEngine { protected _linkForce!: ForceLink>; protected _simulation!: Simulation; protected _settings: ID3SimulatorEngineSettings; diff --git a/src/simulator/engine/types/gpu-simulator-engine.ts b/src/simulator/engine/types/gpu-simulator-engine.ts new file mode 100644 index 0000000..53ead79 --- /dev/null +++ b/src/simulator/engine/types/gpu-simulator-engine.ts @@ -0,0 +1,677 @@ +import { IPosition } from '../../../common'; +import { ISimulationGraph, ISimulationIds, ISimulationNode } from '../../shared'; +import { Emitter } from '../../../utils/emitter.utils'; +import { copyObject } from '../../../utils/object.utils'; +import { + ISimulatorEngine, + ISimulatorEngineSettingsConfig, + ISimulatorEngineSettingsUpdate, + SimulatorEngineEventType, + SimulatorEngineEvents, +} from '../shared'; +import { DEFAULT_SETTINGS } from './d3-simulator-engine'; +import { compileShader, ShaderType } from '../../../utils/shaders.utils'; +import forceVertSource from '../shaders/force/force.vert'; +import forceFragSource from '../shaders/force/force.frag'; +import copyVertSource from '../shaders/copy/copy.vert'; +import copyFragSource from '../shaders/copy/copy.frag'; +import { OrbError } from '../../../exceptions'; + +/** + * GPU-accelerated simulator engine using WebGL2 transform feedback. + * + * Phase 1: Skeleton that falls back to CPU-based Euler integration. + * Phase 2: N-body repulsion via transform feedback (O(n^2) pairwise on GPU). + * Phase 3: Full GPU simulation with link forces via adjacency textures. + */ +export class GPUSimulatorEngine extends Emitter implements ISimulatorEngine { + private readonly _gl: WebGL2RenderingContext; + + private _settings: ISimulatorEngineSettingsConfig; + private _initialSettings: ISimulatorEngineSettingsConfig | undefined; + + private _nodes: ISimulationNode[] = []; + private _edges: { id: number; source: number | ISimulationNode; target: number | ISimulationNode }[] = []; + private _nodeIndexByNodeId: Record = {}; + + private _isStabilizing = false; + private _isDragging = false; + + private _bufferA: WebGLBuffer | null = null; + private _bufferB: WebGLBuffer | null = null; + private _transformFeedback: WebGLTransformFeedback | null = null; + private _vaoAtoB: WebGLVertexArrayObject | null = null; + private _vaoBtoA: WebGLVertexArrayObject | null = null; + + private _forceProgram: WebGLProgram | null = null; + private _copyProgram: WebGLProgram | null = null; + private _positionTexture: WebGLTexture | null = null; + private _copyFBO: WebGLFramebuffer | null = null; + private _copyVaoA: WebGLVertexArrayObject | null = null; + private _copyVaoB: WebGLVertexArrayObject | null = null; + private _texWidth = 0; + + private _pingPong = true; + + private static readonly FLOATS_PER_NODE = 7; + + constructor(settings?: ISimulatorEngineSettingsConfig) { + super(); + + if (settings !== undefined) { + this._initialSettings = Object.assign(copyObject(DEFAULT_SETTINGS), settings); + } + + this._settings = this.resetSettings(); + + const gl = document.createElement('canvas').getContext('webgl2'); + if (!gl) { + throw new OrbError('Failed to create WebGL context.'); + } + this._gl = gl; + + this._initGPU(); + } + + getSettings(): ISimulatorEngineSettingsConfig { + return copyObject(this._settings); + } + + setSettings(settings: ISimulatorEngineSettingsUpdate): void { + const previousSettings = this.getSettings(); + + if (!this._initialSettings) { + this._initialSettings = Object.assign(copyObject(DEFAULT_SETTINGS), settings); + } + + Object.assign(this._settings, settings); + this.emit(SimulatorEngineEventType.SETTINGS_UPDATE, { settings: this._settings }); + + const hasPhysicsBeenDisabled = previousSettings.isPhysicsEnabled && !settings.isPhysicsEnabled; + if (hasPhysicsBeenDisabled) { + this.stopSimulation(); + } else if (this._settings.isSimulatingOnSettingsUpdate) { + this.activateSimulation(); + } + } + + resetSettings(): ISimulatorEngineSettingsConfig { + return Object.assign(copyObject(DEFAULT_SETTINGS), this._initialSettings); + } + + setupData(data: ISimulationGraph): void { + this.clearData(); + this._ingestData(data); + + if (this._settings.isSimulatingOnDataUpdate) { + this._runSimulation(); + } + } + + mergeData(data: Partial): void { + this._ingestData(data); + + if (this._settings.isSimulatingOnDataUpdate) { + this.activateSimulation(); + } + } + + updateData(data: ISimulationGraph): void { + const newNodeIds = new Set(data.nodes.map((n) => n.id)); + const oldNodes = this._nodes.filter((n) => newNodeIds.has(n.id)); + const newNodes = data.nodes.filter((n) => this._nodeIndexByNodeId[n.id] === undefined); + + this._nodes = [...oldNodes, ...newNodes]; + this._edges = data.edges as any; + this._rebuildIndex(); + + if (this._settings.isSimulatingOnSettingsUpdate) { + this.activateSimulation(); + } + } + + deleteData(data: Partial): void { + const nodeIds = new Set(data.nodeIds); + const edgeIds = new Set(data.edgeIds); + this._nodes = this._nodes.filter((n) => !nodeIds.has(n.id)); + this._edges = this._edges.filter((e) => !edgeIds.has(e.id)); + this._rebuildIndex(); + + if (this._settings.isSimulatingOnDataUpdate) { + this.activateSimulation(); + } + } + + patchData(data: Partial): void { + if (data.nodes) { + for (const node of data.nodes) { + const idx = this._nodeIndexByNodeId[node.id]; + if (idx !== undefined) { + this._nodes[idx] = node; + } else { + this._nodes.push(node); + } + } + this._rebuildIndex(); + } + if (data.edges) { + this._edges = this._edges.concat(data.edges as any); + } + } + + clearData(): void { + const nodes = this._nodes; + const edges = this._edges; + this._nodes = []; + this._edges = []; + this._nodeIndexByNodeId = {}; + this.emit(SimulatorEngineEventType.DATA_CLEARED, { + nodes, + edges: edges as any, + }); + } + + activateSimulation(): void { + if (!this._settings.isPhysicsEnabled) { + this.fixNodes(); + } + this._runSimulation(); + } + + stopSimulation(): void { + this._isStabilizing = false; + } + + resetSimulation(): void { + this.emit(SimulatorEngineEventType.SIMULATION_RESET, { + nodes: this._nodes, + edges: this._edges as any, + }); + } + + startDragNode(): void { + this._isDragging = true; + } + + dragNode(data: { id: number } & IPosition): void { + const node = this._nodes[this._nodeIndexByNodeId[data.id]]; + if (!node) { + return; + } + + if (!this._isDragging) { + this.startDragNode(); + } + + node.fx = data.x; + node.fy = data.y; + + if (!this._settings.isPhysicsEnabled) { + node.x = data.x; + node.y = data.y; + } + + this.emit(SimulatorEngineEventType.NODE_DRAG, { + nodes: this._nodes, + edges: this._edges as any, + }); + } + + endDragNode(data: { id: number }): void { + this._isDragging = false; + const node = this._nodes[this._nodeIndexByNodeId[data.id]]; + if (node && this._settings.isPhysicsEnabled) { + this.unfixNode(node); + } + } + + fixNodes(nodes?: ISimulationNode[]): void { + const targets = nodes ?? this._nodes; + for (const node of targets) { + this.fixNode(node); + } + } + + unfixNodes(nodes?: ISimulationNode[]): void { + const targets = nodes ?? this._nodes; + for (const node of targets) { + this.unfixNode(node); + } + } + + stickNodes(nodes?: ISimulationNode[]): void { + const targets = nodes ?? this._nodes; + for (const node of targets) { + node.sx = node.x; + node.fx = node.x; + node.sy = node.y; + node.fy = node.y; + } + } + + unstickNodes(nodes?: ISimulationNode[]): void { + const targets = nodes ?? this._nodes; + for (const node of targets) { + node.sx = null; + node.sy = null; + if (this._settings.isPhysicsEnabled) { + node.fx = null; + node.fy = null; + } + } + if (this._settings.isSimulatingOnUnstick) { + this.activateSimulation(); + } + } + + // --- GPU simulation via WebGL2 transform feedback --- + + private _runSimulation(): void { + if (this._isStabilizing) { + return; + } + + this._isStabilizing = true; + this.emit(SimulatorEngineEventType.SIMULATION_START, undefined); + + // Assign random initial positions to nodes that don't have one + for (const node of this._nodes) { + if (node.x === undefined || node.x === null) { + node.x = (Math.random() - 0.5) * this._nodes.length; + } + if (node.y === undefined || node.y === null) { + node.y = (Math.random() - 0.5) * this._nodes.length; + } + } + + // Upload current node data to GPU buffers + position texture + this._uploadDataToGPU(); + + const alphaSettings = this._settings.alpha; + let alpha = alphaSettings.alpha; + const alphaMin = alphaSettings.alphaMin; + const alphaDecay = alphaSettings.alphaDecay; + + const totalSteps = Math.ceil(Math.log(alphaMin) / Math.log(1 - alphaDecay)); + + let lastProgressBucket = -1; + for (let step = 0; step < totalSteps; step++) { + alpha += (alphaSettings.alphaTarget - alpha) * alphaDecay; + if (alpha < alphaMin) { + break; + } + + this._simulateGPUStep(alpha); + + const progressBucket = Math.floor((step * 100) / totalSteps); + if (progressBucket > lastProgressBucket) { + lastProgressBucket = progressBucket; + + this._readbackFromGPU(); + + this.emit(SimulatorEngineEventType.SIMULATION_PROGRESS, { + nodes: this._nodes, + edges: this._edges as any, + progress: progressBucket / 10, + }); + } + } + + this._readbackFromGPU(); + + if (!this._settings.isPhysicsEnabled) { + this.fixNodes(); + } + + this._isStabilizing = false; + this.emit(SimulatorEngineEventType.SIMULATION_END, { + nodes: this._nodes, + edges: this._edges as any, + }); + } + + private fixNode(node: ISimulationNode): void { + if (node.sx === null || node.sx === undefined) { + node.fx = node.x; + } + if (node.sy === null || node.sy === undefined) { + node.fy = node.y; + } + } + + private unfixNode(node: ISimulationNode): void { + if (node.sx === null || node.sx === undefined) { + node.fx = null; + } + if (node.sy === null || node.sy === undefined) { + node.fy = null; + } + } + + private _ingestData(data: Partial): void { + if (data.nodes) { + for (const node of data.nodes) { + if (node.x !== null && node.x !== undefined) { + node.fx = node.x; + node.sx = node.x; + } + if (node.y !== null && node.y !== undefined) { + node.fy = node.y; + node.sy = node.y; + } + this._nodes.push(node); + } + } + if (data.edges) { + this._edges = this._edges.concat(data.edges as any); + } + this._rebuildIndex(); + } + + private _rebuildIndex(): void { + this._nodeIndexByNodeId = {}; + for (let i = 0; i < this._nodes.length; i++) { + this._nodeIndexByNodeId[this._nodes[i].id] = i; + } + } + + 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.transformFeedbackVaryings(program, ['vPosition', 'vVelocity', 'vFixed', 'vFixedPos'], gl.INTERLEAVED_ATTRIBS); + 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._bufferA = gl.createBuffer(); + this._bufferB = gl.createBuffer(); + + this._transformFeedback = gl.createTransformFeedback(); + + this._vaoAtoB = this._createVAO(this._bufferA); + this._vaoBtoA = this._createVAO(this._bufferB); + + this._initCopyProgram(); + } + + private _createVAO(buffer: WebGLBuffer | null): WebGLVertexArrayObject { + const gl = this._gl; + const program = this._forceProgram; + if (!program) { + throw new OrbError('Force program not initialized.'); + } + + const vao = gl.createVertexArray(); + if (!vao) { + throw new OrbError('Failed to create vertex array object.'); + } + + const STRIDE = GPUSimulatorEngine.FLOATS_PER_NODE * 4; + + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + + // aPosition (vec2) — offset 0 + const posLoc = gl.getAttribLocation(program, 'aPosition'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, STRIDE, 0); + + // aVelocity (vec2) — offset 2*4 = 8 + const velLoc = gl.getAttribLocation(program, 'aVelocity'); + gl.enableVertexAttribArray(velLoc); + gl.vertexAttribPointer(velLoc, 2, gl.FLOAT, false, STRIDE, 2 * 4); + + // aFixed (float) — offset 4*4 = 16 + const fixedLoc = gl.getAttribLocation(program, 'aFixed'); + gl.enableVertexAttribArray(fixedLoc); + gl.vertexAttribPointer(fixedLoc, 1, gl.FLOAT, false, STRIDE, 4 * 4); + + // aFixedPos (vec2) — offset 5*4 = 20 + const fixedPosLoc = gl.getAttribLocation(program, 'aFixedPos'); + gl.enableVertexAttribArray(fixedPosLoc); + gl.vertexAttribPointer(fixedPosLoc, 2, gl.FLOAT, false, STRIDE, 5 * 4); + + gl.bindVertexArray(null); + return vao; + } + + private _initCopyProgram(): void { + const gl = this._gl; + + const vs = compileShader(gl, copyVertSource, ShaderType.VERTEX); + const fs = compileShader(gl, copyFragSource, ShaderType.FRAGMENT); + const program = gl.createProgram(); + if (!program) { + throw new OrbError('Failed to create copy program.'); + } + this._copyProgram = 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 copy program: ${info}`); + } + + this._copyFBO = gl.createFramebuffer(); + + this._copyVaoA = this._createCopyVAO(this._bufferA); + this._copyVaoB = this._createCopyVAO(this._bufferB); + } + + private _createCopyVAO(buffer: WebGLBuffer | null): WebGLVertexArrayObject { + const gl = this._gl; + const program = this._copyProgram; + if (!program) { + throw new OrbError('Copy program not initialized.'); + } + + const vao = gl.createVertexArray(); + if (!vao) { + throw new OrbError('Failed to create copy VAO.'); + } + + const STRIDE = GPUSimulatorEngine.FLOATS_PER_NODE * 4; + + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + + const posLoc = gl.getAttribLocation(program, 'aPosition'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, STRIDE, 0); + + gl.bindVertexArray(null); + return vao; + } + + private _uploadDataToGPU(): void { + const gl = this._gl; + const N = this._nodes.length; + const FPN = GPUSimulatorEngine.FLOATS_PER_NODE; + + const data = new Float32Array(N * FPN); + + for (let i = 0; i < N; i++) { + const node = this._nodes[i]; + const off = i * FPN; + data[off] = node.x ?? 0; + data[off + 1] = node.y ?? 0; + data[off + 2] = node.vx ?? 0; + data[off + 3] = node.vy ?? 0; + data[off + 4] = node.fx !== null && node.fx !== undefined ? 1.0 : 0.0; + data[off + 5] = node.fx ?? node.x ?? 0; + data[off + 6] = node.fy ?? node.y ?? 0; + } + + gl.bindBuffer(gl.ARRAY_BUFFER, this._bufferA); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); + gl.bindBuffer(gl.ARRAY_BUFFER, this._bufferB); + gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); + + // Reset ping-pong state so the first tick reads from bufferA + this._pingPong = true; + + this._updatePositionTexture(); + } + + private _updatePositionTexture(): void { + const gl = this._gl; + const N = this._nodes.length; + this._texWidth = Math.ceil(Math.sqrt(N)); + const texSize = this._texWidth * this._texWidth; + + const texData = new Float32Array(texSize * 4); + for (let i = 0; i < N; i++) { + texData[i * 4] = this._nodes[i].x ?? 0; + texData[i * 4 + 1] = this._nodes[i].y ?? 0; + } + + if (!this._positionTexture) { + this._positionTexture = gl.createTexture(); + } + + gl.bindTexture(gl.TEXTURE_2D, this._positionTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, this._texWidth, this._texWidth, 0, gl.RGBA, gl.FLOAT, texData); + 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); + + // Set uniforms + gl.uniform1i(gl.getUniformLocation(program, 'uNodeCount'), N); + gl.uniform1i(gl.getUniformLocation(program, 'uTexWidth'), this._texWidth); + gl.uniform1f(gl.getUniformLocation(program, 'uAlpha'), alpha); + gl.uniform1f(gl.getUniformLocation(program, 'uRepulsionStrength'), this._settings.manyBody?.strength ?? -30); + gl.uniform2f( + gl.getUniformLocation(program, 'uCenter'), + this._settings.centering?.x ?? 0, + this._settings.centering?.y ?? 0, + ); + gl.uniform1f(gl.getUniformLocation(program, 'uCenterStrength'), this._settings.centering?.strength ?? 1); + gl.uniform1f(gl.getUniformLocation(program, 'uDamping'), 0.6); + + // Bind position texture to unit 0 + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._positionTexture); + gl.uniform1i(gl.getUniformLocation(program, 'uPositions'), 0); + + // Ping-pong: read from one buffer, write to the other + const readVAO = this._pingPong ? this._vaoAtoB : this._vaoBtoA; + const writeBuffer = this._pingPong ? this._bufferB : this._bufferA; + + gl.bindVertexArray(readVAO); + + // Bind transform feedback to the write buffer + gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, this._transformFeedback); + gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, writeBuffer); + + // Disable rasterization — we only want the transform feedback output + gl.enable(gl.RASTERIZER_DISCARD); + + gl.beginTransformFeedback(gl.POINTS); + gl.drawArrays(gl.POINTS, 0, N); + gl.endTransformFeedback(); + + gl.disable(gl.RASTERIZER_DISCARD); + + gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null); + gl.bindVertexArray(null); + + // Swap for next tick + this._pingPong = !this._pingPong; + + // Sync position texture from the output buffer for the next tick + this._updatePositionTextureFromBuffer(); + } + + /** + * GPU-only position texture update via render-to-texture. + * Renders N points into the position texture FBO — each point writes its + * position to the texel corresponding to its vertex ID. No CPU readback. + */ + private _updatePositionTextureFromBuffer(): void { + const gl = this._gl; + const program = this._copyProgram; + if (!program) { + throw new OrbError('Copy program not initialized.'); + } + const N = this._nodes.length; + if (N === 0) { + return; + } + + // After the swap, the "read" side holds the latest data + const copyVao = this._pingPong ? this._copyVaoA : this._copyVaoB; + + gl.useProgram(program); + gl.uniform1i(gl.getUniformLocation(program, 'uTexWidth'), this._texWidth); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this._copyFBO); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._positionTexture, 0); + + gl.viewport(0, 0, this._texWidth, this._texWidth); + + gl.bindVertexArray(copyVao); + gl.drawArrays(gl.POINTS, 0, N); + gl.bindVertexArray(null); + + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + /** + * Reads final positions/velocities from the latest GPU buffer back to CPU nodes. + * Called once after the simulation loop completes. + */ + private _readbackFromGPU(): void { + const gl = this._gl; + const N = this._nodes.length; + const FPN = GPUSimulatorEngine.FLOATS_PER_NODE; + const data = new Float32Array(N * FPN); + + // After the loop, _pingPong points to the next "read" buffer = latest data + const latestBuffer = this._pingPong ? this._bufferA : this._bufferB; + gl.bindBuffer(gl.ARRAY_BUFFER, latestBuffer); + gl.getBufferSubData(gl.ARRAY_BUFFER, 0, data); + + for (let i = 0; i < N; i++) { + const node = this._nodes[i]; + const off = i * FPN; + node.x = data[off]; + node.y = data[off + 1]; + node.vx = data[off + 2]; + node.vy = data[off + 3]; + } + } +} diff --git a/src/simulator/factory.ts b/src/simulator/factory.ts index e902e6f..3d5d6c5 100644 --- a/src/simulator/factory.ts +++ b/src/simulator/factory.ts @@ -1,10 +1,18 @@ import { ISimulator } from './shared'; import { MainThreadSimulator } from './types/main-thread-simulator'; import { WebWorkerSimulator } from './types/web-worker-simulator/web-worker-simulator'; +import { SimulatorEngineType } from './engine/shared'; +import { SimulatorEngineFactory } from './engine/factory'; // TODO(dlozic & Alex): CORS handling export class SimulatorFactory { - static getSimulator(): ISimulator { + static getSimulator(engineType?: SimulatorEngineType): ISimulator { + // GPU engine requires main thread (needs WebGL context) + if (engineType === SimulatorEngineType.GPU) { + const engine = SimulatorEngineFactory.getEngine(SimulatorEngineType.GPU); + return new MainThreadSimulator(engine); + } + try { if (typeof Worker !== 'undefined') { return new WebWorkerSimulator(); diff --git a/src/simulator/index.ts b/src/simulator/index.ts index 2c02f6d..0f06ff5 100644 --- a/src/simulator/index.ts +++ b/src/simulator/index.ts @@ -1,2 +1,4 @@ export { SimulatorFactory } from './factory'; export { ISimulator, ISimulationNode, ISimulationEdge } from './shared'; +export { SimulatorEngineType, ISimulatorEngine } from './engine/shared'; +export { SimulatorEngineFactory } from './engine/factory'; diff --git a/src/simulator/shared.ts b/src/simulator/shared.ts index b1190de..562a591 100644 --- a/src/simulator/shared.ts +++ b/src/simulator/shared.ts @@ -1,6 +1,6 @@ import { IPosition } from '../common'; import { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'; -import { ID3SimulatorEngineSettings, ID3SimulatorEngineSettingsUpdate } from './engine/d3-simulator-engine'; +import { ID3SimulatorEngineSettings, ID3SimulatorEngineSettingsUpdate } from './engine/types/d3-simulator-engine'; import { IEmitter } from '../utils/emitter.utils'; /** diff --git a/src/simulator/types/main-thread-simulator.ts b/src/simulator/types/main-thread-simulator.ts index b560b33..6241955 100644 --- a/src/simulator/types/main-thread-simulator.ts +++ b/src/simulator/types/main-thread-simulator.ts @@ -8,34 +8,32 @@ import { } from '../shared'; import { IPosition } from '../../common'; import { Emitter } from '../../utils/emitter.utils'; -import { - D3SimulatorEngine, - D3SimulatorEngineEventType, - ID3SimulatorEngineSettingsUpdate, -} from '../engine/d3-simulator-engine'; +import { ID3SimulatorEngineSettingsUpdate } from '../engine/types/d3-simulator-engine'; +import { ISimulatorEngine, SimulatorEngineEventType } from '../engine/shared'; +import { SimulatorEngineFactory } from '../engine/factory'; export class MainThreadSimulator extends Emitter implements ISimulator { - protected readonly _simulator: D3SimulatorEngine; + protected readonly _simulator: ISimulatorEngine; - constructor() { + constructor(engine?: ISimulatorEngine) { super(); - this._simulator = new D3SimulatorEngine(); - this._simulator.on(D3SimulatorEngineEventType.SIMULATION_START, () => { + this._simulator = engine ?? SimulatorEngineFactory.getEngine(); + this._simulator.on(SimulatorEngineEventType.SIMULATION_START, () => { this.emit(SimulatorEventType.SIMULATION_START, undefined); }); - this._simulator.on(D3SimulatorEngineEventType.SIMULATION_PROGRESS, (data) => { + this._simulator.on(SimulatorEngineEventType.SIMULATION_PROGRESS, (data) => { this.emit(SimulatorEventType.SIMULATION_PROGRESS, data); }); - this._simulator.on(D3SimulatorEngineEventType.SIMULATION_END, (data) => { + this._simulator.on(SimulatorEngineEventType.SIMULATION_END, (data) => { this.emit(SimulatorEventType.SIMULATION_END, data); }); - this._simulator.on(D3SimulatorEngineEventType.NODE_DRAG, (data) => { + this._simulator.on(SimulatorEngineEventType.NODE_DRAG, (data) => { this.emit(SimulatorEventType.NODE_DRAG, data); }); - this._simulator.on(D3SimulatorEngineEventType.SIMULATION_TICK, (data) => { + this._simulator.on(SimulatorEngineEventType.SIMULATION_TICK, (data) => { this.emit(SimulatorEventType.SIMULATION_STEP, data); }); - this._simulator.on(D3SimulatorEngineEventType.SETTINGS_UPDATE, (data) => { + this._simulator.on(SimulatorEngineEventType.SETTINGS_UPDATE, (data) => { this.emit(SimulatorEventType.SETTINGS_UPDATE, data); }); } diff --git a/src/simulator/types/web-worker-simulator/message/worker-input.ts b/src/simulator/types/web-worker-simulator/message/worker-input.ts index c252d4a..ea2d885 100644 --- a/src/simulator/types/web-worker-simulator/message/worker-input.ts +++ b/src/simulator/types/web-worker-simulator/message/worker-input.ts @@ -1,6 +1,6 @@ import { IPosition } from '../../../../common'; import { ISimulationNode, ISimulationEdge } from '../../../shared'; -import { ID3SimulatorEngineSettingsUpdate } from '../../../engine/d3-simulator-engine'; +import { ID3SimulatorEngineSettingsUpdate } from '../../../engine/types/d3-simulator-engine'; import { IWorkerPayload } from './worker-payload'; // Messages are objects going into the simulation worker. diff --git a/src/simulator/types/web-worker-simulator/message/worker-output.ts b/src/simulator/types/web-worker-simulator/message/worker-output.ts index d271bcf..0db5b65 100644 --- a/src/simulator/types/web-worker-simulator/message/worker-output.ts +++ b/src/simulator/types/web-worker-simulator/message/worker-output.ts @@ -1,6 +1,6 @@ import { ISimulationNode, ISimulationEdge } from '../../../shared'; import { IWorkerPayload } from './worker-payload'; -import { ID3SimulatorEngineSettings } from '../../../engine/d3-simulator-engine'; +import { ID3SimulatorEngineSettings } from '../../../engine/types/d3-simulator-engine'; export enum WorkerOutputType { SIMULATION_START = 'simulation-start', diff --git a/src/simulator/types/web-worker-simulator/simulator.worker.ts b/src/simulator/types/web-worker-simulator/simulator.worker.ts index f07da1d..d74ea3b 100644 --- a/src/simulator/types/web-worker-simulator/simulator.worker.ts +++ b/src/simulator/types/web-worker-simulator/simulator.worker.ts @@ -1,5 +1,5 @@ // / -import { D3SimulatorEngine, D3SimulatorEngineEventType } from '../../engine/d3-simulator-engine'; +import { D3SimulatorEngine, D3SimulatorEngineEventType } from '../../engine/types/d3-simulator-engine'; import { IWorkerInputPayload, WorkerInputType } from './message/worker-input'; import { IWorkerOutputPayload, WorkerOutputType } from './message/worker-output'; diff --git a/src/simulator/types/web-worker-simulator/web-worker-simulator.ts b/src/simulator/types/web-worker-simulator/web-worker-simulator.ts index 005d043..7c8a275 100644 --- a/src/simulator/types/web-worker-simulator/web-worker-simulator.ts +++ b/src/simulator/types/web-worker-simulator/web-worker-simulator.ts @@ -8,7 +8,7 @@ import { ISimulationGraph, ISimulationIds, } from '../../shared'; -import { ID3SimulatorEngineSettingsUpdate } from '../../engine/d3-simulator-engine'; +import { ID3SimulatorEngineSettingsUpdate } from '../../engine/types/d3-simulator-engine'; import { IWorkerInputPayload, WorkerInputType } from './message/worker-input'; import { IWorkerOutputPayload, WorkerOutputType } from './message/worker-output'; import { Emitter } from '../../../utils/emitter.utils'; diff --git a/src/renderer/webgl/utils/program.utils.ts b/src/utils/program.utils.ts similarity index 94% rename from src/renderer/webgl/utils/program.utils.ts rename to src/utils/program.utils.ts index ebe2f62..e4530d5 100644 --- a/src/renderer/webgl/utils/program.utils.ts +++ b/src/utils/program.utils.ts @@ -1,4 +1,4 @@ -import { OrbError } from '../../../exceptions'; +import { OrbError } from '../exceptions'; import { compileShader, ShaderType } from './shaders.utils'; export const createProgram = ( diff --git a/src/renderer/webgl/utils/shaders.utils.ts b/src/utils/shaders.utils.ts similarity index 93% rename from src/renderer/webgl/utils/shaders.utils.ts rename to src/utils/shaders.utils.ts index 64b29cf..60d966c 100644 --- a/src/renderer/webgl/utils/shaders.utils.ts +++ b/src/utils/shaders.utils.ts @@ -1,4 +1,4 @@ -import { OrbError } from '../../../exceptions'; +import { OrbError } from '../exceptions'; export enum ShaderType { VERTEX = 'vertex', diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts index 16ea79d..88544e2 100644 --- a/src/views/orb-view.ts +++ b/src/views/orb-view.ts @@ -7,7 +7,7 @@ import transition from 'd3-transition'; import { D3ZoomEvent, zoom, ZoomBehavior } from 'd3-zoom'; import { select } from 'd3-selection'; import { IPosition, isEqualPosition } from '../common'; -import { ISimulator, SimulatorFactory } from '../simulator'; +import { ISimulator, SimulatorFactory, SimulatorEngineType } from '../simulator'; import { Graph, IGraph, INodeFilter, IEdgeFilter } from '../models/graph'; import { INode, INodeBase, isNode } from '../models/node'; import { IEdge, IEdgeBase, isEdge } from '../models/edge'; @@ -15,10 +15,9 @@ import { IOrbView } from './shared'; import { DefaultEventStrategy, IEventStrategy, IEventStrategySettings } from '../models/strategy'; import { DEFAULT_SETTINGS, - ID3SimulatorEngineSettings, ID3SimulatorEngineSettingsCentering, ID3SimulatorEngineSettingsLinks, -} from '../simulator/engine/d3-simulator-engine'; +} from '../simulator/engine/types/d3-simulator-engine'; import { copyObject } from '../utils/object.utils'; import { OrbEmitter, OrbEventType } from '../events'; import { IRenderer, RenderEventType, IRendererSettingsInit, IRendererSettings } from '../renderer/shared'; @@ -29,6 +28,7 @@ import { isBoolean } from '../utils/type.utils'; import { IObserver, IObserverDataPayload } from '../utils/observer.utils'; import { ILayoutSettings, LayoutFactory } from '../simulator/layout/layout'; import { DEFAULT_FORCE_LAYOUT_OPTIONS } from '../simulator/layout/layouts/force'; +import { ISimulatorEngineSettingsConfig } from '../simulator/engine/shared'; export interface IGraphInteractionSettings { isDragEnabled: boolean; @@ -37,7 +37,7 @@ export interface IGraphInteractionSettings { export interface IOrbViewSettings { getPosition?(node: INode): IPosition | undefined; - simulation: Partial; + simulation: Partial & { engineType?: SimulatorEngineType }; render: Partial; strategy: Partial; interaction: Partial; @@ -160,7 +160,7 @@ export class OrbView implements IOrbVi .on('contextmenu', this.mouseRightClicked) .on('dblclick.zoom', this.mouseDoubleClicked); - this._simulator = SimulatorFactory.getSimulator(); + this._simulator = SimulatorFactory.getSimulator(this._settings.simulation.engineType); if (this._settings.layout.type === 'force') { this._enableSimulation(); From a27afd28ab55eaf822fbcb6f60c3263b52876e97 Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Mon, 9 Mar 2026 11:18:15 +0100 Subject: [PATCH 3/5] New: Add WebGL improved node/shape geometry options --- src/renderer/webgl/shaders/edge/edge.frag | 122 +++++++++-- src/renderer/webgl/shaders/edge/edge.vert | 80 +++++-- src/renderer/webgl/shaders/node/node.frag | 100 ++++++++- src/renderer/webgl/shaders/node/node.vert | 3 + src/renderer/webgl/webgl-renderer.ts | 241 ++++++++++++++++------ 5 files changed, 443 insertions(+), 103 deletions(-) diff --git a/src/renderer/webgl/shaders/edge/edge.frag b/src/renderer/webgl/shaders/edge/edge.frag index 985fe5f..b9a7a1a 100644 --- a/src/renderer/webgl/shaders/edge/edge.frag +++ b/src/renderer/webgl/shaders/edge/edge.frag @@ -2,35 +2,131 @@ precision highp float; -in vec2 vLocalPos; +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 vEdgeRatio; -in float vShadowOffsetPerp; -in float vShadowBlur; +in float vShadowSize; +in vec2 vShadowOffset; +flat in int vEdgeType; 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() { - float perpDist = abs(vLocalPos.y); - float shadowPerpDist = abs(vLocalPos.y - vShadowOffsetPerp); + float cutoff = vHalfWidth + vShadowSize + length(vShadowOffset) + vArrowSize + 2.0; + + float dist; + float shadowDist; + vec2 shadowPos = vWorldPos - vShadowOffset; + + if (vEdgeType == 0) { + dist = sdSegment(vWorldPos, vStart, vEnd); + shadowDist = sdSegment(shadowPos, vStart, vEnd); + } else if (vEdgeType == 1) { + dist = sdBezier(vWorldPos, vStart, vControl, vEnd); + shadowDist = sdBezier(shadowPos, vStart, vControl, vEnd); + } else { + dist = abs(length(vWorldPos - vControl) - vLoopbackRadius); + shadowDist = abs(length(shadowPos - vControl) - vLoopbackRadius); + } + + float arrowDist = sdArrow(vWorldPos, vArrowTip, vArrowDir, vArrowSize); + float shadowArrowDist = sdArrow(shadowPos, vArrowTip, vArrowDir, vArrowSize); + + float edgeSdf = dist - vHalfWidth; + float combinedSdf = min(edgeSdf, arrowDist); + float shadowCombined = min(shadowDist - vHalfWidth, shadowArrowDist); float shadowAlpha = 0.0; - if (vShadowBlur > 0.0) { - float t = max(shadowPerpDist - vEdgeRatio, 0.0) / vShadowBlur; + if (vShadowSize > 0.0) { + float t = max(shadowCombined, 0.0) / vShadowSize; shadowAlpha = exp(-t * t * 1.5) * 0.5 * vShadowColor.a; } - float aa = 0.02 * vEdgeRatio; - float edgeAlpha = 1.0 - smoothstep(vEdgeRatio - aa, vEdgeRatio, perpDist); + 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 (finalAlpha < 0.001) discard; vec3 finalRGB = (edgeColor.rgb * edgeColor.a + vShadowColor.rgb * shadowAlpha * (1.0 - edgeColor.a)) / finalAlpha; fragColor = vec4(finalRGB, finalAlpha); diff --git a/src/renderer/webgl/shaders/edge/edge.vert b/src/renderer/webgl/shaders/edge/edge.vert index 23385df..63f5b62 100644 --- a/src/renderer/webgl/shaders/edge/edge.vert +++ b/src/renderer/webgl/shaders/edge/edge.vert @@ -6,7 +6,13 @@ 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; @@ -18,38 +24,70 @@ uniform vec2 uTranslation; uniform float uScale; uniform vec2 uOriginOffset; -out vec2 vLocalPos; +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 vEdgeRatio; -out float vShadowOffsetPerp; -out float vShadowBlur; +out float vShadowSize; +out vec2 vShadowOffset; +flat out int vEdgeType; void main() { + vEdgeType = int(aEdgeType + 0.5); + vStart = aStart; + vEnd = aEnd; + vControl = aControl; + vHalfWidth = aWidth * 0.5; + vLoopbackRadius = aLoopbackRadius; + vArrowSize = aArrowSize; + vArrowTip = aArrowTip; + vArrowDir = aArrowDir; vColor = aColor; vShadowColor = aShadowColor; - vLocalPos = aQuadPosition; + vShadowSize = aShadowSize; + vShadowOffset = vec2(aShadowOffsetX, aShadowOffsetY); - vec2 dir = aEnd - aStart; - float len = length(dir); - vec2 unitDir = dir / max(len, 0.0001); - vec2 perp = vec2(-unitDir.y, unitDir.x); + float pad = vHalfWidth + aShadowSize + abs(aShadowOffsetX) + abs(aShadowOffsetY); - float halfWidth = aWidth * 0.5; + vec2 worldPos; - float shadowOffPerp = dot(vec2(aShadowOffsetX, aShadowOffsetY), perp); - - float totalHalfWidth = halfWidth + aShadowSize + abs(shadowOffPerp); - - vEdgeRatio = halfWidth / max(totalHalfWidth, 0.0001); - vShadowOffsetPerp = shadowOffPerp / max(totalHalfWidth, 0.0001); - vShadowBlur = aShadowSize / max(totalHalfWidth, 0.0001); - - vec2 midpoint = (aStart + aEnd) * 0.5; - vec2 worldPos = midpoint + unitDir * (len * 0.5) * aQuadPosition.x + perp * totalHalfWidth * aQuadPosition.y; + 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/node/node.frag b/src/renderer/webgl/shaders/node/node.frag index 83e24eb..501cb1e 100644 --- a/src/renderer/webgl/shaders/node/node.frag +++ b/src/renderer/webgl/shaders/node/node.frag @@ -10,22 +10,112 @@ in vec4 vShadowColor; in float vNodeRadius; in vec2 vShadowOffset; in float vShadowBlur; +flat in int vShapeType; 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() { - float dist = length(vUV); - float shadowDist = length(vUV - vShadowOffset); + float dist = shapeSDF(vUV, vNodeRadius, vShapeType); + float shadowDist = shapeSDF(vUV - vShadowOffset, vNodeRadius, vShapeType); float shadowAlpha = 0.0; if (vShadowBlur > 0.0) { - float t = max(shadowDist - vNodeRadius, 0.0) / vShadowBlur; + float t = max(shadowDist, 0.0) / vShadowBlur; shadowAlpha = exp(-t * t * 1.5) * 0.5 * vShadowColor.a; } float aa = 0.02 * vNodeRadius; - float nodeAlpha = 1.0 - smoothstep(vNodeRadius - aa, vNodeRadius, dist); - float borderMix = smoothstep(vBorderThreshold - aa, vBorderThreshold + aa, dist); + float nodeAlpha = 1.0 - smoothstep(-aa, 0.0, dist); + + float borderDist = shapeSDF(vUV, vBorderThreshold, vShapeType); + float borderMix = smoothstep(-aa, aa, borderDist); vec4 nodeColor = mix(vColor, vBorderColor, borderMix); nodeColor.a *= nodeAlpha; diff --git a/src/renderer/webgl/shaders/node/node.vert b/src/renderer/webgl/shaders/node/node.vert index e7aa0cb..6855847 100644 --- a/src/renderer/webgl/shaders/node/node.vert +++ b/src/renderer/webgl/shaders/node/node.vert @@ -13,6 +13,7 @@ in vec4 aShadowColor; in float aShadowSize; in float aShadowOffsetX; in float aShadowOffsetY; +in float aShapeType; uniform vec2 uResolution; uniform vec2 uTranslation; @@ -27,8 +28,10 @@ out vec4 vShadowColor; out float vNodeRadius; out vec2 vShadowOffset; out float vShadowBlur; +flat out int vShapeType; void main() { + vShapeType = int(aShapeType + 0.5); vColor = aColor; vBorderColor = aBorderColor; vShadowColor = aShadowColor; diff --git a/src/renderer/webgl/webgl-renderer.ts b/src/renderer/webgl/webgl-renderer.ts index cc1a019..18dc0ac 100644 --- a/src/renderer/webgl/webgl-renderer.ts +++ b/src/renderer/webgl/webgl-renderer.ts @@ -1,6 +1,6 @@ import { zoomIdentity, ZoomTransform } from 'd3-zoom'; import { INode, INodeBase } from '../../models/node'; -import { IEdge, IEdgeBase } from '../../models/edge'; +import { IEdge, IEdgeBase, EdgeCurved, EdgeLoopback } from '../../models/edge'; import { IGraph } from '../../models/graph'; import { Color, IPosition, IRectangle } from '../../common'; import { Emitter } from '../../utils/emitter.utils'; @@ -16,11 +16,27 @@ 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'; +const EDGE_TYPE_STRAIGHT = 0; +const EDGE_TYPE_CURVED = 1; +const EDGE_TYPE_LOOPBACK = 2; + +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, +}; + type RGBAFloats = [number, number, number, number]; export class WebGLRenderer extends Emitter implements IRenderer { @@ -110,7 +126,7 @@ export class WebGLRenderer extends Emi this._nodeInstanceBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this._nodeInstanceBuffer); - const INSTANCE_STRIDE = 19 * Float32Array.BYTES_PER_ELEMENT; + const INSTANCE_STRIDE = 20 * Float32Array.BYTES_PER_ELEMENT; const centerLoc = gl.getAttribLocation(this._nodeProgram, 'aCenter'); gl.enableVertexAttribArray(centerLoc); @@ -157,6 +173,11 @@ export class WebGLRenderer extends Emi gl.vertexAttribPointer(shadowOffsetYLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 18 * 4); gl.vertexAttribDivisor(shadowOffsetYLoc, 1); + const shapeTypeLoc = gl.getAttribLocation(this._nodeProgram, 'aShapeType'); + gl.enableVertexAttribArray(shapeTypeLoc); + gl.vertexAttribPointer(shapeTypeLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 19 * 4); + gl.vertexAttribDivisor(shapeTypeLoc, 1); + gl.bindVertexArray(null); } @@ -182,47 +203,28 @@ export class WebGLRenderer extends Emi this._edgeInstanceBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this._edgeInstanceBuffer); - const STRIDE = 16 * 4; - - const startLoc = gl.getAttribLocation(this._edgeProgram, 'aStart'); - gl.enableVertexAttribArray(startLoc); - gl.vertexAttribPointer(startLoc, 2, gl.FLOAT, false, STRIDE, 0); - gl.vertexAttribDivisor(startLoc, 1); - - const endLoc = gl.getAttribLocation(this._edgeProgram, 'aEnd'); - gl.enableVertexAttribArray(endLoc); - gl.vertexAttribPointer(endLoc, 2, gl.FLOAT, false, STRIDE, 2 * 4); - gl.vertexAttribDivisor(endLoc, 1); - - const widthLoc = gl.getAttribLocation(this._edgeProgram, 'aWidth'); - gl.enableVertexAttribArray(widthLoc); - gl.vertexAttribPointer(widthLoc, 1, gl.FLOAT, false, STRIDE, 4 * 4); - gl.vertexAttribDivisor(widthLoc, 1); - - const colorLoc = gl.getAttribLocation(this._edgeProgram, 'aColor'); - gl.enableVertexAttribArray(colorLoc); - gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, STRIDE, 5 * 4); - gl.vertexAttribDivisor(colorLoc, 1); - - const shadowColorLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowColor'); - gl.enableVertexAttribArray(shadowColorLoc); - gl.vertexAttribPointer(shadowColorLoc, 4, gl.FLOAT, false, STRIDE, 9 * 4); - gl.vertexAttribDivisor(shadowColorLoc, 1); - - const shadowSizeLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowSize'); - gl.enableVertexAttribArray(shadowSizeLoc); - gl.vertexAttribPointer(shadowSizeLoc, 1, gl.FLOAT, false, STRIDE, 13 * 4); - gl.vertexAttribDivisor(shadowSizeLoc, 1); - - const shadowOffsetXLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowOffsetX'); - gl.enableVertexAttribArray(shadowOffsetXLoc); - gl.vertexAttribPointer(shadowOffsetXLoc, 1, gl.FLOAT, false, STRIDE, 14 * 4); - gl.vertexAttribDivisor(shadowOffsetXLoc, 1); + 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); + }; - const shadowOffsetYLoc = gl.getAttribLocation(this._edgeProgram, 'aShadowOffsetY'); - gl.enableVertexAttribArray(shadowOffsetYLoc); - gl.vertexAttribPointer(shadowOffsetYLoc, 1, gl.FLOAT, false, STRIDE, 15 * 4); - gl.vertexAttribDivisor(shadowOffsetYLoc, 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); } @@ -345,7 +347,7 @@ export class WebGLRenderer extends Emi gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); const edges = graph.getEdges(); - const FLOATS_PER_EDGE = 16; + const FLOATS_PER_EDGE = 25; const edgeData = new Float32Array(edges.length * FLOATS_PER_EDGE); if (edges.length !== this._lastEdgeCount || this._isColorCacheDirty) { @@ -365,33 +367,143 @@ export class WebGLRenderer extends Emi const shadowColor = this._edgeShadowColorCache.get(edge.id) || [0, 0, 0, 0]; const off = i * FLOATS_PER_EDGE; - let width; - let rgba; + const width = edge.getWidth(); + const rgba = + edge.isHovered() || edge.isSelected() + ? this._resolveColor(edge.getColor()) + : this._edgeColorCache.get(edge.id) || [0.6, 0.6, 0.6, 1]; + + 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; + } - if (edge.isHovered() || edge.isSelected()) { - width = edge.getWidth(); - rgba = this._resolveColor(edge.getColor()); - } else { - width = edge.getWidth(); - rgba = this._edgeColorCache.get(edge.id) || [0.6, 0.6, 0.6, 1]; + const scaleFactor = edge.getStyle().arrowSize ?? 1; + if (scaleFactor > 0) { + const lineWidth = width || 1; + arrowSize = 1.5 * scaleFactor + 3 * lineWidth; + + if (edgeType === EDGE_TYPE_STRAIGHT) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const len = Math.sqrt(dx * dx + dy * dy); + if (len > 0) { + arrowDirX = dx / len; + arrowDirY = dy / len; + const borderDist = edge.endNode.getDistanceToBorder(); + arrowTipX = end.x - arrowDirX * borderDist; + arrowTipY = end.y - arrowDirY * borderDist; + } + } else if (edgeType === EDGE_TYPE_CURVED) { + const targetCenter = edge.endNode.getCenter(); + const borderDist = edge.endNode.getDistanceToBorder(); + 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 * start.x + 2 * mid * mt * controlX + mid * mid * end.x; + const py = mt * mt * start.y + 2 * mid * mt * controlY + mid * mid * end.y; + const d = Math.sqrt((px - targetCenter.x) ** 2 + (py - targetCenter.y) ** 2); + if (Math.abs(d - borderDist) < 0.1) { + bestT = mid; + break; + } + if (d > borderDist) { + low = mid; + } else { + high = mid; + } + bestT = mid; + } + + const mt = 1 - bestT; + arrowTipX = mt * mt * start.x + 2 * bestT * mt * controlX + bestT * bestT * end.x; + arrowTipY = mt * mt * start.y + 2 * bestT * mt * controlY + bestT * bestT * end.y; + const tx = 2 * mt * (controlX - start.x) + 2 * bestT * (end.x - controlX); + const ty = 2 * mt * (controlY - start.y) + 2 * bestT * (end.y - controlY); + const tLen = Math.sqrt(tx * tx + ty * ty); + if (tLen > 0) { + arrowDirX = tx / tLen; + arrowDirY = ty / tLen; + } + } else { + const nodeCenter = edge.startNode.getCenter(); + const borderDist = edge.startNode.getDistanceToBorder(); + 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 - nodeCenter.x) ** 2 + (py - nodeCenter.y) ** 2); + if (Math.abs(d - borderDist) < 0.1) { + bestT = mid; + break; + } + if (d > borderDist) { + 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] = start.x; edgeData[off + 1] = start.y; edgeData[off + 2] = end.x; edgeData[off + 3] = end.y; - edgeData[off + 4] = width; - edgeData[off + 5] = rgba[0]; - edgeData[off + 6] = rgba[1]; - edgeData[off + 7] = rgba[2]; - edgeData[off + 8] = rgba[3]; - edgeData[off + 9] = shadowColor[0]; - edgeData[off + 10] = shadowColor[1]; - edgeData[off + 11] = shadowColor[2]; - edgeData[off + 12] = shadowColor[3]; - edgeData[off + 13] = shadowSize; - edgeData[off + 14] = shadowOffsetX; - edgeData[off + 15] = shadowOffsetY; + 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); @@ -406,7 +518,7 @@ export class WebGLRenderer extends Emi this._setViewUniforms(this._nodeProgram); const nodes = graph.getNodes(); - const FLOATS_PER_NODE = 19; + const FLOATS_PER_NODE = 20; const instanceData = new Float32Array(nodes.length * FLOATS_PER_NODE); if (nodes.length !== this._lastNodeCount || this._isColorCacheDirty) { @@ -460,6 +572,7 @@ export class WebGLRenderer extends Emi instanceData[off + 16] = shadowSize; instanceData[off + 17] = shadowOffsetX; instanceData[off + 18] = shadowOffsetY; + instanceData[off + 19] = SHAPE_TYPE_MAP[node.getStyle().shape ?? NodeShapeType.CIRCLE] ?? 0; } gl.bindBuffer(gl.ARRAY_BUFFER, this._nodeInstanceBuffer); From f832a73154545b44910b64b2faafea7057403766 Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Mon, 9 Mar 2026 12:20:04 +0100 Subject: [PATCH 4/5] New: Add labels and node images --- src/renderer/webgl/shaders/label/label.frag | 15 ++ src/renderer/webgl/shaders/label/label.vert | 25 ++ src/renderer/webgl/shaders/node/node.frag | 22 +- src/renderer/webgl/shaders/node/node.vert | 9 + src/renderer/webgl/utils/image-atlas.ts | 180 +++++++++++++ src/renderer/webgl/utils/label-cache.ts | 181 +++++++++++++ src/renderer/webgl/webgl-renderer.ts | 273 ++++++++++++++++---- 7 files changed, 650 insertions(+), 55 deletions(-) create mode 100644 src/renderer/webgl/shaders/label/label.frag create mode 100644 src/renderer/webgl/shaders/label/label.vert create mode 100644 src/renderer/webgl/utils/image-atlas.ts create mode 100644 src/renderer/webgl/utils/label-cache.ts 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 index 501cb1e..06c0327 100644 --- a/src/renderer/webgl/shaders/node/node.frag +++ b/src/renderer/webgl/shaders/node/node.frag @@ -11,6 +11,11 @@ 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; @@ -114,9 +119,24 @@ void main() { float aa = 0.02 * vNodeRadius; float nodeAlpha = 1.0 - smoothstep(-aa, 0.0, dist); + 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); + } + } + float borderDist = shapeSDF(vUV, vBorderThreshold, vShapeType); float borderMix = smoothstep(-aa, aa, borderDist); - vec4 nodeColor = mix(vColor, vBorderColor, borderMix); + vec4 nodeColor = mix(fillColor, vBorderColor, borderMix); nodeColor.a *= nodeAlpha; float finalAlpha = nodeColor.a + shadowAlpha * (1.0 - nodeColor.a); diff --git a/src/renderer/webgl/shaders/node/node.vert b/src/renderer/webgl/shaders/node/node.vert index 6855847..96fb121 100644 --- a/src/renderer/webgl/shaders/node/node.vert +++ b/src/renderer/webgl/shaders/node/node.vert @@ -14,6 +14,9 @@ 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; @@ -29,12 +32,18 @@ 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); 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 18dc0ac..4fd7858 100644 --- a/src/renderer/webgl/webgl-renderer.ts +++ b/src/renderer/webgl/webgl-renderer.ts @@ -21,6 +21,10 @@ 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; @@ -37,6 +41,14 @@ const SHAPE_TYPE_MAP: Record = { [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; +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 { @@ -56,12 +68,18 @@ export class WebGLRenderer extends Emi 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(); @@ -97,11 +115,15 @@ export class WebGLRenderer extends Emi this._initShaders(); this._initNodeBuffers(); this._initEdgeBuffers(); + this._initLabelBuffers(); + this._labelCache = new LabelCache(this._gl); + this._imageAtlas = new ImageAtlas(this._gl); } 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 { @@ -126,57 +148,27 @@ export class WebGLRenderer extends Emi this._nodeInstanceBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, this._nodeInstanceBuffer); - const INSTANCE_STRIDE = 20 * Float32Array.BYTES_PER_ELEMENT; - - const centerLoc = gl.getAttribLocation(this._nodeProgram, 'aCenter'); - gl.enableVertexAttribArray(centerLoc); - gl.vertexAttribPointer(centerLoc, 2, gl.FLOAT, false, INSTANCE_STRIDE, 0); - gl.vertexAttribDivisor(centerLoc, 1); - - const radiusLoc = gl.getAttribLocation(this._nodeProgram, 'aRadius'); - gl.enableVertexAttribArray(radiusLoc); - gl.vertexAttribPointer(radiusLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 2 * 4); - gl.vertexAttribDivisor(radiusLoc, 1); - - const colorLoc = gl.getAttribLocation(this._nodeProgram, 'aColor'); - gl.enableVertexAttribArray(colorLoc); - gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, INSTANCE_STRIDE, 3 * 4); - gl.vertexAttribDivisor(colorLoc, 1); - - const borderColorLoc = gl.getAttribLocation(this._nodeProgram, 'aBorderColor'); - gl.enableVertexAttribArray(borderColorLoc); - gl.vertexAttribPointer(borderColorLoc, 4, gl.FLOAT, false, INSTANCE_STRIDE, 7 * 4); - gl.vertexAttribDivisor(borderColorLoc, 1); - - const borderWidthLoc = gl.getAttribLocation(this._nodeProgram, 'aBorderWidth'); - gl.enableVertexAttribArray(borderWidthLoc); - gl.vertexAttribPointer(borderWidthLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 11 * 4); - gl.vertexAttribDivisor(borderWidthLoc, 1); - - const shadowColorLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowColor'); - gl.enableVertexAttribArray(shadowColorLoc); - gl.vertexAttribPointer(shadowColorLoc, 4, gl.FLOAT, false, INSTANCE_STRIDE, 12 * 4); - gl.vertexAttribDivisor(shadowColorLoc, 1); - - const shadowSizeLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowSize'); - gl.enableVertexAttribArray(shadowSizeLoc); - gl.vertexAttribPointer(shadowSizeLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 16 * 4); - gl.vertexAttribDivisor(shadowSizeLoc, 1); - - const shadowOffsetXLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowOffsetX'); - gl.enableVertexAttribArray(shadowOffsetXLoc); - gl.vertexAttribPointer(shadowOffsetXLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 17 * 4); - gl.vertexAttribDivisor(shadowOffsetXLoc, 1); - - const shadowOffsetYLoc = gl.getAttribLocation(this._nodeProgram, 'aShadowOffsetY'); - gl.enableVertexAttribArray(shadowOffsetYLoc); - gl.vertexAttribPointer(shadowOffsetYLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 18 * 4); - gl.vertexAttribDivisor(shadowOffsetYLoc, 1); - - const shapeTypeLoc = gl.getAttribLocation(this._nodeProgram, 'aShapeType'); - gl.enableVertexAttribArray(shapeTypeLoc); - gl.vertexAttribPointer(shapeTypeLoc, 1, gl.FLOAT, false, INSTANCE_STRIDE, 19 * 4); - gl.vertexAttribDivisor(shapeTypeLoc, 1); + 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); } @@ -229,6 +221,44 @@ export class WebGLRenderer extends Emi 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]; @@ -326,8 +356,8 @@ export class WebGLRenderer extends Emi } render(graph: IGraph): void { - if (!this._nodeProgram || !this._edgeProgram) { - throw new OrbError('Node or edge program not initialized.'); + if (!this._nodeProgram || !this._edgeProgram || !this._labelProgram) { + throw new OrbError('Shader programs not initialized.'); } const gl = this._gl; @@ -517,8 +547,15 @@ export class WebGLRenderer extends Emi 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 FLOATS_PER_NODE = 20; + const zoom = this.transform.k; + const FLOATS_PER_NODE = 25; const instanceData = new Float32Array(nodes.length * FLOATS_PER_NODE); if (nodes.length !== this._lastNodeCount || this._isColorCacheDirty) { @@ -573,6 +610,31 @@ export class WebGLRenderer extends Emi instanceData[off + 17] = shadowOffsetX; instanceData[off + 18] = shadowOffsetY; instanceData[off + 19] = SHAPE_TYPE_MAP[node.getStyle().shape ?? NodeShapeType.CIRCLE] ?? 0; + + const style = node.getStyle(); + 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() ? style.imageUrlSelected || style.imageUrl : style.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); @@ -582,6 +644,109 @@ export class WebGLRenderer extends Emi gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, nodes.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; } From 65f437eb8a1d8eea91394b530f9d19dcccf06a6f Mon Sep 17 00:00:00 2001 From: AlexIchenskiy Date: Fri, 29 May 2026 11:41:09 +0200 Subject: [PATCH 5/5] Fix: GPU drag behavior --- package.json | 2 +- src/renderer/webgl/shaders/edge/edge.frag | 46 +- src/renderer/webgl/shaders/edge/edge.vert | 3 +- src/renderer/webgl/shaders/node/node.frag | 30 +- src/renderer/webgl/webgl-renderer.ts | 614 +++++++++----- .../engines/dynamic/force-layout-engine.ts | 65 +- .../dynamic/gpu-force-layout-engine.ts | 777 ++++++++++++------ src/simulator/engine/shaders/copy/copy.frag | 10 - src/simulator/engine/shaders/copy/copy.vert | 18 - src/simulator/engine/shaders/force/force.frag | 212 ++++- src/simulator/engine/shaders/force/force.vert | 59 +- src/simulator/engine/shared.ts | 3 +- .../engine/utils/adjacency-builder.ts | 105 +++ .../engine/utils/quadtree-builder.ts | 245 ++++++ src/views/orb-map-view.ts | 55 +- src/views/orb-view.ts | 25 +- 16 files changed, 1685 insertions(+), 584 deletions(-) delete mode 100644 src/simulator/engine/shaders/copy/copy.frag delete mode 100644 src/simulator/engine/shaders/copy/copy.vert create mode 100644 src/simulator/engine/utils/adjacency-builder.ts create mode 100644 src/simulator/engine/utils/quadtree-builder.ts 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/renderer/webgl/shaders/edge/edge.frag b/src/renderer/webgl/shaders/edge/edge.frag index b9a7a1a..f2efc8f 100644 --- a/src/renderer/webgl/shaders/edge/edge.frag +++ b/src/renderer/webgl/shaders/edge/edge.frag @@ -17,6 +17,8 @@ in float vShadowSize; in vec2 vShadowOffset; flat in int vEdgeType; +uniform bool uSimpleMode; + out vec4 fragColor; float sdSegment(vec2 p, vec2 a, vec2 b) { @@ -89,32 +91,44 @@ float sdArrow(vec2 p, vec2 tip, vec2 dir, float size) { } void main() { - float cutoff = vHalfWidth + vShadowSize + length(vShadowOffset) + vArrowSize + 2.0; + if (uSimpleMode && vEdgeType == 0) { + fragColor = vColor; + return; + } + // Edge SDF: type-based dispatch (always needed). float dist; - float shadowDist; - vec2 shadowPos = vWorldPos - vShadowOffset; - if (vEdgeType == 0) { dist = sdSegment(vWorldPos, vStart, vEnd); - shadowDist = sdSegment(shadowPos, vStart, vEnd); } else if (vEdgeType == 1) { dist = sdBezier(vWorldPos, vStart, vControl, vEnd); - shadowDist = sdBezier(shadowPos, vStart, vControl, vEnd); } else { dist = abs(length(vWorldPos - vControl) - vLoopbackRadius); - shadowDist = abs(length(shadowPos - vControl) - vLoopbackRadius); } - float arrowDist = sdArrow(vWorldPos, vArrowTip, vArrowDir, vArrowSize); - float shadowArrowDist = sdArrow(shadowPos, vArrowTip, vArrowDir, vArrowSize); - float edgeSdf = dist - vHalfWidth; - float combinedSdf = min(edgeSdf, arrowDist); - float shadowCombined = min(shadowDist - vHalfWidth, shadowArrowDist); + 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; } @@ -128,6 +142,10 @@ void main() { if (finalAlpha < 0.001) discard; - vec3 finalRGB = (edgeColor.rgb * edgeColor.a + vShadowColor.rgb * shadowAlpha * (1.0 - edgeColor.a)) / finalAlpha; - fragColor = vec4(finalRGB, finalAlpha); + 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 index 63f5b62..8a881e4 100644 --- a/src/renderer/webgl/shaders/edge/edge.vert +++ b/src/renderer/webgl/shaders/edge/edge.vert @@ -44,7 +44,8 @@ void main() { vStart = aStart; vEnd = aEnd; vControl = aControl; - vHalfWidth = aWidth * 0.5; + float effectiveWidth = max(aWidth, 1.0 / uScale); + vHalfWidth = effectiveWidth * 0.5; vLoopbackRadius = aLoopbackRadius; vArrowSize = aArrowSize; vArrowTip = aArrowTip; diff --git a/src/renderer/webgl/shaders/node/node.frag b/src/renderer/webgl/shaders/node/node.frag index 06c0327..edce819 100644 --- a/src/renderer/webgl/shaders/node/node.frag +++ b/src/renderer/webgl/shaders/node/node.frag @@ -107,18 +107,21 @@ float shapeSDF(vec2 p, float r, int shapeType) { } void main() { + // Body SDF - always needed. float dist = shapeSDF(vUV, vNodeRadius, vShapeType); - float shadowDist = shapeSDF(vUV - vShadowOffset, 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; } - float aa = 0.02 * vNodeRadius; - float nodeAlpha = 1.0 - smoothstep(-aa, 0.0, dist); - vec4 fillColor = vColor; if (vImageAspect > 0.0 && dist < 0.0) { vec2 uv01 = (vUV / vNodeRadius) * 0.5 + 0.5; @@ -134,9 +137,14 @@ void main() { } } - float borderDist = shapeSDF(vUV, vBorderThreshold, vShapeType); - float borderMix = smoothstep(-aa, aa, borderDist); - vec4 nodeColor = mix(fillColor, vBorderColor, borderMix); + 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); @@ -145,6 +153,10 @@ void main() { discard; } - vec3 finalRGB = (nodeColor.rgb * nodeColor.a + vShadowColor.rgb * shadowAlpha * (1.0 - nodeColor.a)) / finalAlpha; - fragColor = vec4(finalRGB, finalAlpha); + 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/webgl-renderer.ts b/src/renderer/webgl/webgl-renderer.ts index 4fd7858..73362a1 100644 --- a/src/renderer/webgl/webgl-renderer.ts +++ b/src/renderer/webgl/webgl-renderer.ts @@ -11,6 +11,7 @@ import { IRenderer, RendererEvents as RE, IRendererSettings, + RenderEventType, } from '../shared'; import { copyObject } from '../../utils/object.utils'; import { appendCanvas, setupContainer } from '../../utils/html.utils'; @@ -30,6 +31,13 @@ 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, @@ -46,6 +54,10 @@ 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; @@ -92,6 +104,18 @@ export class WebGLRenderer extends Emi 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); @@ -118,6 +142,46 @@ export class WebGLRenderer extends Emi 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 { @@ -344,6 +408,14 @@ export class WebGLRenderer extends Emi return this._isInitiallyRendered; } + invalidateBuffers(): void { + this._buffersAreCurrent = false; + } + + getRenderCacheStats(): { hits: number; misses: number } { + return { ...this._bufferCacheStats }; + } + getSettings(): IRendererSettings { return copyObject(this._settings); } @@ -360,13 +432,18 @@ export class WebGLRenderer extends Emi 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(); - this._canvas.width = rect.width; - this._canvas.height = rect.height; - this._width = rect.width; - this._height = rect.height; + 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); @@ -378,172 +455,255 @@ export class WebGLRenderer extends Emi const edges = graph.getEdges(); const FLOATS_PER_EDGE = 25; - const edgeData = new Float32Array(edges.length * FLOATS_PER_EDGE); + 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 (edges.length !== this._lastEdgeCount || this._isColorCacheDirty) { + if (!canSkipRebuild && (edges.length !== this._lastEdgeCount || this._isColorCacheDirty)) { this._buildEdgeColorCache(edges); this._buildEdgeShadowColorCache(edges); this._isColorCacheDirty = false; this._lastEdgeCount = edges.length; } - for (let i = 0; i < edges.length; i++) { - const edge = edges[i]; - const start = edge.startNode.getCenter(); - const end = edge.endNode.getCenter(); - const shadowSize = edge.getStyle().shadowSize || 0; - const shadowOffsetX = edge.getStyle().shadowOffsetX || 0; - const shadowOffsetY = edge.getStyle().shadowOffsetY || 0; - const shadowColor = this._edgeShadowColorCache.get(edge.id) || [0, 0, 0, 0]; - 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) || [0.6, 0.6, 0.6, 1]; - - 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; - } + 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 = edge.getStyle().arrowSize ?? 1; - if (scaleFactor > 0) { - const lineWidth = width || 1; - arrowSize = 1.5 * scaleFactor + 3 * lineWidth; - - if (edgeType === EDGE_TYPE_STRAIGHT) { - const dx = end.x - start.x; - const dy = end.y - start.y; - const len = Math.sqrt(dx * dx + dy * dy); - if (len > 0) { - arrowDirX = dx / len; - arrowDirY = dy / len; - const borderDist = edge.endNode.getDistanceToBorder(); - arrowTipX = end.x - arrowDirX * borderDist; - arrowTipY = end.y - arrowDirY * borderDist; - } - } else if (edgeType === EDGE_TYPE_CURVED) { - const targetCenter = edge.endNode.getCenter(); - const borderDist = edge.endNode.getDistanceToBorder(); - 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 * start.x + 2 * mid * mt * controlX + mid * mid * end.x; - const py = mt * mt * start.y + 2 * mid * mt * controlY + mid * mid * end.y; - const d = Math.sqrt((px - targetCenter.x) ** 2 + (py - targetCenter.y) ** 2); - if (Math.abs(d - borderDist) < 0.1) { - bestT = mid; - break; + 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; } - if (d > borderDist) { - low = mid; - } else { - high = mid; + } 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; } - bestT = mid; - } - const mt = 1 - bestT; - arrowTipX = mt * mt * start.x + 2 * bestT * mt * controlX + bestT * bestT * end.x; - arrowTipY = mt * mt * start.y + 2 * bestT * mt * controlY + bestT * bestT * end.y; - const tx = 2 * mt * (controlX - start.x) + 2 * bestT * (end.x - controlX); - const ty = 2 * mt * (controlY - start.y) + 2 * bestT * (end.y - controlY); - const tLen = Math.sqrt(tx * tx + ty * ty); - if (tLen > 0) { - arrowDirX = tx / tLen; - arrowDirY = ty / tLen; - } - } else { - const nodeCenter = edge.startNode.getCenter(); - const borderDist = edge.startNode.getDistanceToBorder(); - 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 - nodeCenter.x) ** 2 + (py - nodeCenter.y) ** 2); - if (Math.abs(d - borderDist) < 0.1) { - bestT = mid; - break; + 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; } - if (d > borderDist) { - high = mid; - } else { - low = mid; + } 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; } - 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); } - 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] = start.x; - edgeData[off + 1] = start.y; - edgeData[off + 2] = end.x; - edgeData[off + 3] = end.y; - 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; + 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); - gl.bufferData(gl.ARRAY_BUFFER, edgeData, gl.DYNAMIC_DRAW); + 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); @@ -556,9 +716,15 @@ export class WebGLRenderer extends Emi const nodes = graph.getNodes(); const zoom = this.transform.k; const FLOATS_PER_NODE = 25; - const instanceData = new Float32Array(nodes.length * FLOATS_PER_NODE); + 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 (nodes.length !== this._lastNodeCount || this._isColorCacheDirty) { + if (!canSkipNodeRebuild && (nodes.length !== this._lastNodeCount || this._isColorCacheDirty)) { this._buildNodeColorCache(nodes); this._buildNodeBorderColorCache(nodes); this._buildNodeShadowColorCache(nodes); @@ -566,82 +732,103 @@ export class WebGLRenderer extends Emi this._lastNodeCount = nodes.length; } - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - const center = node.getCenter(); - const radius = node.getRadius(); - const shadowSize = node.getStyle().shadowSize || 0; - const shadowOffsetX = node.getStyle().shadowOffsetX || 0; - const shadowOffsetY = node.getStyle().shadowOffsetY || 0; - const shadowColor = this._nodeShadowColorCache.get(node.id) || [0, 0, 0, 0]; - 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) || [1, 0, 0, 1]; - borderColor = this._nodeBorderColorCache.get(node.id) || [0, 0, 0, 0]; - borderWidth = node.getBorderWidth(); - } + 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[node.getStyle().shape ?? NodeShapeType.CIRCLE] ?? 0; - - const style = node.getStyle(); - 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() ? style.imageUrlSelected || style.imageUrl : style.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] = 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; } - 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); - gl.bufferData(gl.ARRAY_BUFFER, instanceData, gl.DYNAMIC_DRAW); + 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) { @@ -748,6 +935,7 @@ export class WebGLRenderer extends Emi } this._isInitiallyRendered = true; + this.emit(RenderEventType.RENDER_END, { durationMs: performance.now() - renderStartedAt }); } reset(): void { 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 index 888ac50..e9ea3af 100644 --- a/src/simulator/engine/engines/dynamic/gpu-force-layout-engine.ts +++ b/src/simulator/engine/engines/dynamic/gpu-force-layout-engine.ts @@ -1,25 +1,24 @@ 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 } from '../../shared'; +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 copyVertSource from '../../shaders/copy/copy.vert'; -import copyFragSource from '../../shaders/copy/copy.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 = 500; - -/** - * GPU-accelerated force layout engine using WebGL2 transform feedback. - * - * Phase 1: Skeleton that falls back to CPU-based Euler integration. - * Phase 2: N-body repulsion via transform feedback (O(n^2) pairwise on GPU). - * Phase 3: Full GPU simulation with link forces via adjacency textures. - */ +const CHUNK_SIZE = 1; + export class GPUForceLayoutEngine extends BaseLayoutEngine { private readonly _gl: WebGL2RenderingContext; @@ -28,25 +27,43 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { private _isStabilizing = false; private _isDragging = false; + private _dragLoopRunning = false; + private _pendingRestart = false; + private _simulationGeneration = 0; + + private _currentAlpha = 0; + private _currentStep = 0; + private _totalSteps = 0; - // WebGL resources - private _bufferA: WebGLBuffer | null = null; - private _bufferB: WebGLBuffer | null = null; - private _transformFeedback: WebGLTransformFeedback | null = null; - private _vaoAtoB: WebGLVertexArrayObject | null = null; - private _vaoBtoA: WebGLVertexArrayObject | null = null; + private _dragAlpha = 0; + private _dragNeedsReheat = false; + + private _dirtyNodes: Set = new Set(); private _forceProgram: WebGLProgram | null = null; - private _copyProgram: WebGLProgram | null = null; - private _positionTexture: WebGLTexture | null = null; - private _copyFBO: WebGLFramebuffer | null = null; - private _copyVaoA: WebGLVertexArrayObject | null = null; - private _copyVaoB: WebGLVertexArrayObject | 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 static readonly FLOATS_PER_NODE = 7; + private _uniforms: Record = {}; readonly type: LayoutType = 'force'; @@ -123,6 +140,7 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { this._nodes = [...oldNodes, ...newNodes]; this._rebuildNodeIndex(); this._edges = data.edges; + this._cachedAdjacency = null; if (this._settings.isSimulatingOnSettingsUpdate) { this.activateSimulation(); @@ -139,6 +157,7 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { this._edges = this._edges.filter((edge) => !edgeIds.has(edge.id)); } this._rebuildNodeIndex(); + this._cachedAdjacency = null; if (this._settings.isSimulatingOnDataUpdate) { this.activateSimulation(); @@ -186,6 +205,7 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { this._nodes = []; this._edges = []; this._rebuildNodeIndex(); + this._cachedAdjacency = null; } activateSimulation() { @@ -194,23 +214,44 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { } else { this._pinNodes(); } - this._runSimulation(); + + if (this._isStabilizing) { + this._pendingRestart = true; + return; + } + + this._ensurePositions(); + this._uploadDataToGPU(); + if (!this._cachedAdjacency) { + this._buildAndUploadAdjacency(); + } + + this._startSimulationLoop(); } stopSimulation() { - this._cancelSimulation = true; + 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; - if (!this._isStabilizing && this._settings.isPhysicsEnabled) { - this.activateSimulation(); + // 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 node = this._nodes[this._nodeIndexByNodeId[nodeId]]; + const nodeIndex = this._nodeIndexByNodeId[nodeId]; + const node = this._nodes[nodeIndex]; if (!node) { return; } @@ -227,6 +268,9 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { node.y = position.y; } + this._dirtyNodes.add(nodeIndex); + this._dragNeedsReheat = true; + this.emit(SimulatorEventType.NODE_DRAG, { nodes: this._nodes, edges: this._edges }); } @@ -236,6 +280,8 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { const node = this._nodes[this._nodeIndexByNodeId[nodeId]]; if (node && this._settings.isPhysicsEnabled) { this._unpinNode(node); + const nodeIndex = this._nodeIndexByNodeId[nodeId]; + this._dirtyNodes.add(nodeIndex); } } @@ -266,73 +312,167 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { const gl = this._gl; if (gl) { - gl.deleteBuffer(this._bufferA); - gl.deleteBuffer(this._bufferB); - gl.deleteTransformFeedback(this._transformFeedback); - gl.deleteVertexArray(this._vaoAtoB); - gl.deleteVertexArray(this._vaoBtoA); + gl.deleteBuffer(this._quadBuffer); + gl.deleteVertexArray(this._quadVAO); gl.deleteProgram(this._forceProgram); - gl.deleteProgram(this._copyProgram); - gl.deleteTexture(this._positionTexture); - gl.deleteFramebuffer(this._copyFBO); - gl.deleteVertexArray(this._copyVaoA); - gl.deleteVertexArray(this._copyVaoB); + 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.emit(SimulatorEventType.SIMULATION_START, undefined); - this._isStabilizing = true; + this._ensurePositions(); + this._uploadDataToGPU(); + this._buildAndUploadAdjacency(); + this._startSimulationLoop(); + } - // Assign random initial positions to nodes that don't have one - for (const node of this._nodes) { - if (node.x === undefined || node.x === null) { - node.x = (Math.random() - 0.5) * this._nodes.length; + 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 (node.y === undefined || node.y === null) { - node.y = (Math.random() - 0.5) * this._nodes.length; + + 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._uploadDataToGPU(); + this.emit(SimulatorEventType.SIMULATION_START, undefined); + this._isStabilizing = true; + this._pendingRestart = false; + const generation = ++this._simulationGeneration; const alphaSettings = this._settings.alpha; - let alpha = alphaSettings.alpha; const alphaMin = alphaSettings.alphaMin; const alphaDecay = alphaSettings.alphaDecay; - const totalSteps = Math.min(MAX_SIMULATION_STEPS, Math.ceil(Math.log(alphaMin) / Math.log(1 - 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; - let step = 0; 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; } - const end = Math.min(step + CHUNK_SIZE, totalSteps); + this._flushDirtyNodes(); + this._buildAndUploadQuadTree(); - for (; step < end; step++) { - alpha += (alphaSettings.alphaTarget - alpha) * alphaDecay; - if (alpha < alphaMin) { - step = totalSteps; + 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(alpha); + this._simulateGPUStep(this._currentAlpha); } - // Readback once per chunk (minimizes expensive GPU→CPU transfer) this._readbackFromGPU(); + this._applyCentering(); - const currentProgress = Math.round((step * 100) / totalSteps); + const currentProgress = Math.round((this._currentStep * 100) / this._totalSteps); if (currentProgress > lastProgress) { lastProgress = currentProgress; this.emit(SimulatorEventType.SIMULATION_PROGRESS, { @@ -342,7 +482,7 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { }); } - if (step < totalSteps && !this._cancelSimulation) { + if (this._currentStep < this._totalSteps && !this._cancelSimulation) { this._scheduleNext(runChunk); } else { if (!this._settings.isPhysicsEnabled) { @@ -355,7 +495,174 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { } }; - runChunk(); + 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[]) { @@ -444,6 +751,7 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { } this._rebuildNodeIndex(); + this._cachedAdjacency = null; } private _initGPU(): void { @@ -461,8 +769,6 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { gl.attachShader(program, vs); gl.attachShader(program, fs); - - gl.transformFeedbackVaryings(program, ['vPosition', 'vVelocity', 'vFixed', 'vFixedPos'], gl.INTERLEAVED_ATTRIBS); gl.linkProgram(program); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { @@ -470,152 +776,115 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { throw new OrbError(`Failed to link force program: ${info}`); } - this._bufferA = gl.createBuffer(); - this._bufferB = gl.createBuffer(); - - this._transformFeedback = gl.createTransformFeedback(); - - this._vaoAtoB = this._createVAO(this._bufferA); - this._vaoBtoA = this._createVAO(this._bufferB); - - this._initCopyProgram(); - } - - private _createVAO(buffer: WebGLBuffer | null): WebGLVertexArrayObject { - const gl = this._gl; - const program = this._forceProgram; - if (!program) { - throw new OrbError('Force program not initialized.'); - } - - const vao = gl.createVertexArray(); - if (!vao) { - throw new OrbError('Failed to create vertex array object.'); - } - - const STRIDE = GPUForceLayoutEngine.FLOATS_PER_NODE * 4; + this._cacheUniformLocations(program); - gl.bindVertexArray(vao); - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + 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, STRIDE, 0); - - const velLoc = gl.getAttribLocation(program, 'aVelocity'); - gl.enableVertexAttribArray(velLoc); - gl.vertexAttribPointer(velLoc, 2, gl.FLOAT, false, STRIDE, 2 * 4); - - const fixedLoc = gl.getAttribLocation(program, 'aFixed'); - gl.enableVertexAttribArray(fixedLoc); - gl.vertexAttribPointer(fixedLoc, 1, gl.FLOAT, false, STRIDE, 4 * 4); - - const fixedPosLoc = gl.getAttribLocation(program, 'aFixedPos'); - gl.enableVertexAttribArray(fixedPosLoc); - gl.vertexAttribPointer(fixedPosLoc, 2, gl.FLOAT, false, STRIDE, 5 * 4); - + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); gl.bindVertexArray(null); - return vao; - } - - private _initCopyProgram(): void { - const gl = this._gl; - const vs = compileShader(gl, copyVertSource, ShaderType.VERTEX); - const fs = compileShader(gl, copyFragSource, ShaderType.FRAGMENT); - const program = gl.createProgram(); - if (!program) { - throw new OrbError('Failed to create copy program.'); - } - this._copyProgram = program; + 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(); - 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 copy program: ${info}`); - } - - this._copyFBO = gl.createFramebuffer(); - - this._copyVaoA = this._createCopyVAO(this._bufferA); - this._copyVaoB = this._createCopyVAO(this._bufferB); + this._fboA = gl.createFramebuffer(); + this._fboB = gl.createFramebuffer(); } - private _createCopyVAO(buffer: WebGLBuffer | null): WebGLVertexArrayObject { + private _cacheUniformLocations(program: WebGLProgram): void { const gl = this._gl; - const program = this._copyProgram; - if (!program) { - throw new OrbError('Copy program not initialized.'); - } - - const vao = gl.createVertexArray(); - if (!vao) { - throw new OrbError('Failed to create copy VAO.'); + 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); } - - const STRIDE = GPUForceLayoutEngine.FLOATS_PER_NODE * 4; - - gl.bindVertexArray(vao); - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - - const posLoc = gl.getAttribLocation(program, 'aPosition'); - gl.enableVertexAttribArray(posLoc); - gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, STRIDE, 0); - - gl.bindVertexArray(null); - return vao; } private _uploadDataToGPU(): void { const gl = this._gl; const N = this._nodes.length; - const FPN = GPUForceLayoutEngine.FLOATS_PER_NODE; - const data = new Float32Array(N * FPN); + 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 * FPN; - data[off] = node.x ?? 0; - data[off + 1] = node.y ?? 0; - data[off + 2] = node.vx ?? 0; - data[off + 3] = node.vy ?? 0; - data[off + 4] = node.fx !== null && node.fx !== undefined ? 1.0 : 0.0; - data[off + 5] = node.fx ?? node.x ?? 0; - data[off + 6] = node.fy ?? node.y ?? 0; - } - - gl.bindBuffer(gl.ARRAY_BUFFER, this._bufferA); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); - gl.bindBuffer(gl.ARRAY_BUFFER, this._bufferB); - gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_COPY); + 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; - - this._updatePositionTexture(); } - private _updatePositionTexture(): void { + private _uploadTexture(texture: WebGLTexture, data: Float32Array, texWidth: number): void { const gl = this._gl; - const N = this._nodes.length; - this._texWidth = Math.ceil(Math.sqrt(N)); - const texSize = this._texWidth * this._texWidth; - - const texData = new Float32Array(texSize * 4); - for (let i = 0; i < N; i++) { - texData[i * 4] = this._nodes[i].x ?? 0; - texData[i * 4 + 1] = this._nodes[i].y ?? 0; - } - - if (!this._positionTexture) { - this._positionTexture = gl.createTexture(); - } - - gl.bindTexture(gl.TEXTURE_2D, this._positionTexture); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, this._texWidth, this._texWidth, 0, gl.RGBA, gl.FLOAT, texData); + 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); @@ -635,96 +904,114 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine { gl.useProgram(program); - gl.uniform1i(gl.getUniformLocation(program, 'uNodeCount'), N); - gl.uniform1i(gl.getUniformLocation(program, 'uTexWidth'), this._texWidth); - gl.uniform1f(gl.getUniformLocation(program, 'uAlpha'), alpha); - gl.uniform1f(gl.getUniformLocation(program, 'uRepulsionStrength'), this._settings.manyBody?.strength ?? -30); - gl.uniform2f( - gl.getUniformLocation(program, 'uCenter'), - this._settings.centering?.x ?? 0, - this._settings.centering?.y ?? 0, - ); - gl.uniform1f(gl.getUniformLocation(program, 'uCenterStrength'), this._settings.centering?.strength ?? 1); - gl.uniform1f(gl.getUniformLocation(program, 'uDamping'), 0.6); + const u = this._uniforms; - gl.activeTexture(gl.TEXTURE0); - gl.bindTexture(gl.TEXTURE_2D, this._positionTexture); - gl.uniform1i(gl.getUniformLocation(program, 'uPositions'), 0); + gl.uniform1i(u['uNodeCount'], N); + gl.uniform1i(u['uTexWidth'], this._texWidth); + gl.uniform1f(u['uAlpha'], alpha); + gl.uniform1f(u['uDamping'], 0.6); - const readVAO = this._pingPong ? this._vaoAtoB : this._vaoBtoA; - const writeBuffer = this._pingPong ? this._bufferB : this._bufferA; + 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); + } - gl.bindVertexArray(readVAO); + 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.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, this._transformFeedback); - gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, writeBuffer); + gl.uniform1f(u['uHasCentering'], 0.0); - gl.enable(gl.RASTERIZER_DISCARD); + 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); + } - gl.beginTransformFeedback(gl.POINTS); - gl.drawArrays(gl.POINTS, 0, N); - gl.endTransformFeedback(); + 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); + } - gl.disable(gl.RASTERIZER_DISCARD); + const readStateTex = this._pingPong ? this._stateTexA : this._stateTexB; + const writeFBO = this._pingPong ? this._fboB : this._fboA; - gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, null); - gl.bindVertexArray(null); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, readStateTex); + gl.uniform1i(u['uState'], 0); - this._pingPong = !this._pingPong; + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this._fixedTex); + gl.uniform1i(u['uFixed'], 1); - this._updatePositionTextureFromBuffer(); - } + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this._treeDataTexture); + gl.uniform1i(u['uTreeData'], 2); - /** - * GPU-only position texture update via render-to-texture. - * Renders N points into the position texture FBO — each point writes its - * position to the texel corresponding to its vertex ID. No CPU readback. - */ - private _updatePositionTextureFromBuffer(): void { - const gl = this._gl; - const program = this._copyProgram; - if (!program) { - throw new OrbError('Copy program not initialized.'); - } - const N = this._nodes.length; - if (N === 0) { - return; - } + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this._treeChildrenTexture); + gl.uniform1i(u['uTreeChildren'], 3); - const copyVao = this._pingPong ? this._copyVaoA : this._copyVaoB; + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this._adjOffsetsTexture); + gl.uniform1i(u['uAdjOffsets'], 4); - gl.useProgram(program); - gl.uniform1i(gl.getUniformLocation(program, 'uTexWidth'), this._texWidth); + gl.activeTexture(gl.TEXTURE5); + gl.bindTexture(gl.TEXTURE_2D, this._adjEdgesTexture); + gl.uniform1i(u['uAdjEdges'], 5); - gl.bindFramebuffer(gl.FRAMEBUFFER, this._copyFBO); - gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, this._positionTexture, 0); + 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(copyVao); - gl.drawArrays(gl.POINTS, 0, N); + gl.bindVertexArray(this._quadVAO); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); gl.bindVertexArray(null); gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + this._pingPong = !this._pingPong; } - /** - * Reads final positions/velocities from the latest GPU buffer back to CPU nodes. - * Called once per chunk boundary to minimize expensive GPU→CPU transfers. - */ private _readbackFromGPU(): void { const gl = this._gl; const N = this._nodes.length; - const FPN = GPUForceLayoutEngine.FLOATS_PER_NODE; - const data = new Float32Array(N * FPN); + if (N === 0) { + return; + } - const latestBuffer = this._pingPong ? this._bufferA : this._bufferB; - gl.bindBuffer(gl.ARRAY_BUFFER, latestBuffer); - gl.getBufferSubData(gl.ARRAY_BUFFER, 0, data); + 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 * FPN; + const off = i * 4; node.x = data[off]; node.y = data[off + 1]; node.vx = data[off + 2]; diff --git a/src/simulator/engine/shaders/copy/copy.frag b/src/simulator/engine/shaders/copy/copy.frag deleted file mode 100644 index ec3d076..0000000 --- a/src/simulator/engine/shaders/copy/copy.frag +++ /dev/null @@ -1,10 +0,0 @@ -#version 300 es - -precision highp float; - -in vec2 vPos; -out vec4 fragColor; - -void main() { - fragColor = vec4(vPos, 0.0, 0.0); -} diff --git a/src/simulator/engine/shaders/copy/copy.vert b/src/simulator/engine/shaders/copy/copy.vert deleted file mode 100644 index 50321d5..0000000 --- a/src/simulator/engine/shaders/copy/copy.vert +++ /dev/null @@ -1,18 +0,0 @@ -#version 300 es - -precision highp float; - -in vec2 aPosition; - -uniform int uTexWidth; - -out vec2 vPos; - -void main() { - int tx = gl_VertexID % uTexWidth; - int ty = gl_VertexID / uTexWidth; - vec2 ndc = (vec2(float(tx), float(ty)) + 0.5) / float(uTexWidth) * 2.0 - 1.0; - gl_Position = vec4(ndc, 0.0, 1.0); - gl_PointSize = 1.0; - vPos = aPosition; -} diff --git a/src/simulator/engine/shaders/force/force.frag b/src/simulator/engine/shaders/force/force.frag index cdcb8f2..437d471 100644 --- a/src/simulator/engine/shaders/force/force.frag +++ b/src/simulator/engine/shaders/force/force.frag @@ -2,4 +2,214 @@ precision highp float; -void main() {} \ No newline at end of file +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 index 5c7c621..63bfe79 100644 --- a/src/simulator/engine/shaders/force/force.vert +++ b/src/simulator/engine/shaders/force/force.vert @@ -1,62 +1,7 @@ #version 300 es -precision highp float; - in vec2 aPosition; -in vec2 aVelocity; -in float aFixed; // 1.0 if node is fixed, 0.0 if free -in vec2 aFixedPos; - -uniform sampler2D uPositions; -uniform int uNodeCount; -uniform int uTexWidth; -uniform float uAlpha; -uniform float uRepulsionStrength; -uniform vec2 uCenter; -uniform float uCenterStrength; -uniform float uDamping; - -out vec2 vPosition; -out vec2 vVelocity; -out float vFixed; -out vec2 vFixedPos; - -ivec2 idx2uv(int idx) { - return ivec2(idx % uTexWidth, idx / uTexWidth); -} void main() { - int nodeId = gl_VertexID; - - vFixed = aFixed; - vFixedPos = aFixedPos; - - if (aFixed > 0.5) { - vPosition = aFixedPos; - vVelocity = vec2(0.0); - return; - } - - vec2 pos = aPosition; - vec2 vel = aVelocity; - - vec2 repulsion = vec2(0.0); - for (int j = 0; j < uNodeCount; j++) { - if (j == nodeId) { - continue; - } - - vec4 other = texelFetch(uPositions, idx2uv(j), 0); - vec2 delta = other.xy - pos; - float distSq = dot(delta, delta) + 1.0; - repulsion += delta * (uRepulsionStrength * uAlpha / distSq); - } - - vec2 centering = (uCenter - pos) * uCenterStrength * uAlpha; - - vel = (vel + repulsion + centering) * uDamping; - pos = pos + vel; - - vPosition = pos; - vVelocity = vel; -} \ No newline at end of file + gl_Position = vec4(aPosition, 0.0, 1.0); +} diff --git a/src/simulator/engine/shared.ts b/src/simulator/engine/shared.ts index c08bb3e..477d836 100644 --- a/src/simulator/engine/shared.ts +++ b/src/simulator/engine/shared.ts @@ -74,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: { @@ -161,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/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) => {