From db80e661bc24595e94b66fb8d6aa4ce25436c56f Mon Sep 17 00:00:00 2001
From: AlexIchenskiy
Date: Thu, 26 Feb 2026 12:19:48 +0100
Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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) => {
From 9bb6499f5170d827644ea0bd29444e041dc948c6 Mon Sep 17 00:00:00 2001
From: AlexIchenskiy
Date: Thu, 2 Jul 2026 09:02:30 +0200
Subject: [PATCH 6/9] Chore: Remove leftover comments
---
src/renderer/webgl/shaders/edge/edge.frag | 1 -
src/renderer/webgl/webgl-renderer.ts | 3 ---
src/simulator/engine/shaders/force/force.frag | 18 ------------------
3 files changed, 22 deletions(-)
diff --git a/src/renderer/webgl/shaders/edge/edge.frag b/src/renderer/webgl/shaders/edge/edge.frag
index f2efc8f..514b07d 100644
--- a/src/renderer/webgl/shaders/edge/edge.frag
+++ b/src/renderer/webgl/shaders/edge/edge.frag
@@ -96,7 +96,6 @@ void main() {
return;
}
- // Edge SDF: type-based dispatch (always needed).
float dist;
if (vEdgeType == 0) {
dist = sdSegment(vWorldPos, vStart, vEnd);
diff --git a/src/renderer/webgl/webgl-renderer.ts b/src/renderer/webgl/webgl-renderer.ts
index 73362a1..804fc6b 100644
--- a/src/renderer/webgl/webgl-renderer.ts
+++ b/src/renderer/webgl/webgl-renderer.ts
@@ -54,9 +54,6 @@ 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;
diff --git a/src/simulator/engine/shaders/force/force.frag b/src/simulator/engine/shaders/force/force.frag
index 437d471..b7bb64b 100644
--- a/src/simulator/engine/shaders/force/force.frag
+++ b/src/simulator/engine/shaders/force/force.frag
@@ -67,7 +67,6 @@ void main() {
vec2 pos = state.xy;
vec2 vel = state.zw;
- // === ManyBody (Barnes-Hut repulsion) ===
if (uHasManyBody > 0.5 && uTreeNodeCount > 0) {
int stack[128];
int top = 0;
@@ -79,7 +78,6 @@ void main() {
float w = data.w;
if (w < -0.5) {
- // Leaf node
int bodyIdx = int(-w - 0.5);
if (bodyIdx != nodeId) {
vec2 delta = data.xy - pos;
@@ -97,7 +95,6 @@ void main() {
}
}
} else {
- // Internal node - BH approximation check
vec2 delta = data.xy - pos;
float distSq = dot(delta, delta);
@@ -118,11 +115,8 @@ void main() {
}
}
- // === 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;
@@ -134,23 +128,19 @@ void main() {
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);
@@ -165,7 +155,6 @@ void main() {
}
}
- // === Link (spring) force ===
if (uHasLinks > 0.5) {
vec4 offData = texelFetch(uAdjOffsets, texCoord(nodeId, uAdjOffsetsTexWidth), 0);
int start = int(offData.x + 0.5);
@@ -179,11 +168,6 @@ void main() {
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);
@@ -197,12 +181,10 @@ void main() {
}
}
- // === 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;
From ddcf9f7c3be54d69ad5899cfb38d8bd3be5cc647 Mon Sep 17 00:00:00 2001
From: AlexIchenskiy
Date: Wed, 8 Jul 2026 20:08:49 +0200
Subject: [PATCH 7/9] Fix: WebGL renderer style invalidating
---
src/renderer/webgl/webgl-renderer.ts | 5 +++++
src/views/orb-map-view.ts | 10 ++++++++++
src/views/orb-view.ts | 9 +++++++++
3 files changed, 24 insertions(+)
diff --git a/src/renderer/webgl/webgl-renderer.ts b/src/renderer/webgl/webgl-renderer.ts
index 804fc6b..9ff12bc 100644
--- a/src/renderer/webgl/webgl-renderer.ts
+++ b/src/renderer/webgl/webgl-renderer.ts
@@ -409,6 +409,11 @@ export class WebGLRenderer extends Emi
this._buffersAreCurrent = false;
}
+ invalidateStyles(): void {
+ this._buffersAreCurrent = false;
+ this._isColorCacheDirty = true;
+ }
+
getRenderCacheStats(): { hits: number; misses: number } {
return { ...this._bufferCacheStats };
}
diff --git a/src/views/orb-map-view.ts b/src/views/orb-map-view.ts
index b59401d..2bd4f17 100644
--- a/src/views/orb-map-view.ts
+++ b/src/views/orb-map-view.ts
@@ -255,7 +255,12 @@ export class OrbMapView implements IOr
this._leaflet.getContainer().outerHTML = '';
}
+ private _invalidateStyles = (): void => {
+ (this._renderer as any).invalidateStyles?.();
+ };
+
private _update: IObserver = (): void => {
+ this._invalidateStyles();
this.render();
};
@@ -283,6 +288,7 @@ export class OrbMapView implements IOr
leaflet.on('zoom', (event) => {
this._updateGraphPositions();
+ (this._renderer as any).invalidateBuffers?.();
const leafletPos = event.target._mapPane._leaflet_pos;
const k = this._getStyleScale();
this._renderer.transform = { ...leafletPos, k };
@@ -324,6 +330,7 @@ export class OrbMapView implements IOr
});
if (response.isStateChanged) {
+ this._invalidateStyles();
this._renderer.render(this._graph);
}
});
@@ -365,6 +372,7 @@ export class OrbMapView implements IOr
});
if (response.isStateChanged) {
+ this._invalidateStyles();
this._renderer.render(this._graph);
}
} else if (event.type === 'click') {
@@ -398,6 +406,7 @@ export class OrbMapView implements IOr
});
if (response.isStateChanged || response.changedSubject) {
+ this._invalidateStyles();
this._renderer.render(this._graph);
}
} else if (event.type === 'dblclick') {
@@ -437,6 +446,7 @@ export class OrbMapView implements IOr
}
if (response.isStateChanged || response.changedSubject) {
+ this._invalidateStyles();
this._renderer.render(this._graph);
}
}
diff --git a/src/views/orb-view.ts b/src/views/orb-view.ts
index d5af9a4..3f30352 100644
--- a/src/views/orb-view.ts
+++ b/src/views/orb-view.ts
@@ -475,6 +475,7 @@ export class OrbView implements IOrbVi
});
if (response.isStateChanged) {
+ this._invalidateStyles();
this.render();
}
};
@@ -513,6 +514,7 @@ export class OrbView implements IOrbVi
});
if (response.isStateChanged || response.changedSubject) {
+ this._invalidateStyles();
this.render();
}
};
@@ -551,6 +553,7 @@ export class OrbView implements IOrbVi
});
if (response.isStateChanged || response.changedSubject) {
+ this._invalidateStyles();
this.render();
}
};
@@ -589,6 +592,7 @@ export class OrbView implements IOrbVi
});
if (response.isStateChanged || response.changedSubject) {
+ this._invalidateStyles();
this.render();
}
};
@@ -611,6 +615,10 @@ export class OrbView implements IOrbVi
.on('end', () => this.render(onRendered));
};
+ private _invalidateStyles = (): void => {
+ (this._renderer as any).invalidateStyles?.();
+ };
+
private _update: IObserver = (data?: IObserverDataPayload): void => {
if (data && 'x' in data && 'y' in data && 'id' in data) {
this._simulator.patchData({
@@ -628,6 +636,7 @@ export class OrbView implements IOrbVi
edges: [],
});
}
+ this._invalidateStyles();
this.render();
};
From 3963ce9a2952085329ccf2430f3707baff0836ef Mon Sep 17 00:00:00 2001
From: AlexIchenskiy
Date: Thu, 16 Jul 2026 10:20:34 +0200
Subject: [PATCH 8/9] Chore: Refactor code quality
---
package.json | 4 ++
src/renderer/webgl/utils/color.utils.ts | 0
src/renderer/webgl/utils/image-atlas.ts | 38 +++++++++----------
src/renderer/webgl/utils/label-cache.ts | 30 +++++++--------
.../dynamic/gpu-force-layout-engine.ts | 32 ++++++++--------
src/utils/program.utils.ts | 4 +-
6 files changed, 57 insertions(+), 51 deletions(-)
delete mode 100644 src/renderer/webgl/utils/color.utils.ts
diff --git a/package.json b/package.json
index 006c5e5..8004e49 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,10 @@
{
"name": "Toni Lastre",
"url": "https://github.com/tonilastre"
+ },
+ {
+ "name": "Oleksandr Ichenskyi",
+ "url": "https://github.com/AlexIchenskiy"
}
],
"author": "Memgraph Ltd. ",
diff --git a/src/renderer/webgl/utils/color.utils.ts b/src/renderer/webgl/utils/color.utils.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/src/renderer/webgl/utils/image-atlas.ts b/src/renderer/webgl/utils/image-atlas.ts
index 51efcf4..cdaafa6 100644
--- a/src/renderer/webgl/utils/image-atlas.ts
+++ b/src/renderer/webgl/utils/image-atlas.ts
@@ -3,7 +3,7 @@ const ATLAS_HEIGHT = 2048;
const MAX_CELL_SIZE = 128;
const PADDING = 2;
-export interface ImageAtlasEntry {
+export interface IImageAtlasEntry {
u0: number;
v0: number;
u1: number;
@@ -11,13 +11,13 @@ export interface ImageAtlasEntry {
aspect: number;
}
-interface Shelf {
+interface IShelf {
y: number;
height: number;
x: number;
}
-interface PendingImage {
+interface IPendingImage {
image: HTMLImageElement;
loaded: boolean;
}
@@ -27,11 +27,11 @@ export class ImageAtlas {
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;
+ private _cache = new Map();
+ private _pending = new Map();
+ private _shelves: IShelf[] = [];
+ private _isDirty = false;
+ private _isTextureAllocated = false;
constructor(gl: WebGL2RenderingContext) {
this._gl = gl;
@@ -49,7 +49,7 @@ export class ImageAtlas {
gl.bindTexture(gl.TEXTURE_2D, null);
}
- getOrCreate(url: string): ImageAtlasEntry | null {
+ getOrCreate(url: string): IImageAtlasEntry | null {
const cached = this._cache.get(url);
if (cached) {
return cached;
@@ -66,7 +66,7 @@ export class ImageAtlas {
const image = new Image();
image.crossOrigin = 'anonymous';
- const record: PendingImage = { image, loaded: false };
+ const record: IPendingImage = { image, loaded: false };
this._pending.set(url, record);
image.onload = () => {
@@ -87,33 +87,33 @@ export class ImageAtlas {
}
uploadIfDirty(): void {
- if (!this._dirty) {
+ if (!this._isDirty) {
return;
}
const gl = this._gl;
gl.bindTexture(gl.TEXTURE_2D, this._texture);
- if (!this._textureAllocated) {
+ if (!this._isTextureAllocated) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, ATLAS_WIDTH, ATLAS_HEIGHT, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
- this._textureAllocated = true;
+ this._isTextureAllocated = 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;
+ this._isDirty = false;
}
clear(): void {
this._cache.clear();
this._pending.clear();
this._shelves = [];
- this._dirty = false;
+ this._isDirty = false;
this._ctx.clearRect(0, 0, ATLAS_WIDTH, ATLAS_HEIGHT);
}
- private _packImage(url: string, image: HTMLImageElement): ImageAtlasEntry | null {
+ private _packImage(url: string, image: HTMLImageElement): IImageAtlasEntry | null {
if (!image.naturalWidth || !image.naturalHeight) {
return null;
}
@@ -140,7 +140,7 @@ export class ImageAtlas {
this._ctx.drawImage(image, slot.x + PADDING, slot.y + PADDING, drawW, drawH);
- const entry: ImageAtlasEntry = {
+ const entry: IImageAtlasEntry = {
u0: (slot.x + PADDING) / ATLAS_WIDTH,
v0: (slot.y + PADDING) / ATLAS_HEIGHT,
u1: (slot.x + PADDING + drawW) / ATLAS_WIDTH,
@@ -150,7 +150,7 @@ export class ImageAtlas {
this._cache.set(url, entry);
this._pending.delete(url);
- this._dirty = true;
+ this._isDirty = true;
return entry;
}
@@ -173,7 +173,7 @@ export class ImageAtlas {
return null;
}
- const newShelf: Shelf = { y: shelfY, height: h, x: w };
+ const newShelf: IShelf = { 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
index ed90fdf..19434ab 100644
--- a/src/renderer/webgl/utils/label-cache.ts
+++ b/src/renderer/webgl/utils/label-cache.ts
@@ -5,7 +5,7 @@ const FONT_LINE_SPACING = 1.2;
const FONT_BACKGROUND_MARGIN = 0.12;
const PADDING = 2;
-export interface LabelAtlasEntry {
+export interface ILabelAtlasEntry {
u0: number;
v0: number;
u1: number;
@@ -14,7 +14,7 @@ export interface LabelAtlasEntry {
pxHeight: number;
}
-interface Shelf {
+interface IShelf {
y: number;
height: number;
x: number;
@@ -25,10 +25,10 @@ export class LabelCache {
private _canvas: HTMLCanvasElement;
private _ctx: CanvasRenderingContext2D;
private _texture: WebGLTexture | null = null;
- private _cache = new Map();
- private _shelves: Shelf[] = [];
- private _dirty = false;
- private _textureAllocated = false;
+ private _cache = new Map();
+ private _shelves: IShelf[] = [];
+ private _isDirty = false;
+ private _isTextureAllocated = false;
constructor(gl: WebGL2RenderingContext) {
this._gl = gl;
@@ -52,7 +52,7 @@ export class LabelCache {
fontFamily: string,
fontColor: string,
bgColor: string | null,
- ): LabelAtlasEntry | null {
+ ): ILabelAtlasEntry | null {
const key = `${text}|${fontSize}|${fontFamily}|${fontColor}|${bgColor ?? ''}`;
const cached = this._cache.get(key);
if (cached) {
@@ -105,7 +105,7 @@ export class LabelCache {
ctx.fillText(lines[i], centerX, oy + margin + i * lineHeight);
}
- const entry: LabelAtlasEntry = {
+ const entry: ILabelAtlasEntry = {
u0: slot.x / ATLAS_WIDTH,
v0: slot.y / ATLAS_HEIGHT,
u1: (slot.x + pxWidth) / ATLAS_WIDTH,
@@ -115,7 +115,7 @@ export class LabelCache {
};
this._cache.set(key, entry);
- this._dirty = true;
+ this._isDirty = true;
return entry;
}
@@ -126,28 +126,28 @@ export class LabelCache {
}
uploadIfDirty(): void {
- if (!this._dirty) {
+ if (!this._isDirty) {
return;
}
const gl = this._gl;
gl.bindTexture(gl.TEXTURE_2D, this._texture);
- if (!this._textureAllocated) {
+ if (!this._isTextureAllocated) {
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, ATLAS_WIDTH, ATLAS_HEIGHT, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
- this._textureAllocated = true;
+ this._isTextureAllocated = 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;
+ this._isDirty = false;
}
clear(): void {
this._cache.clear();
this._shelves = [];
- this._dirty = false;
+ this._isDirty = false;
this._ctx.clearRect(0, 0, ATLAS_WIDTH, ATLAS_HEIGHT);
}
@@ -174,7 +174,7 @@ export class LabelCache {
return null;
}
- const newShelf: Shelf = { y: shelfY, height: h, x: w };
+ const newShelf: IShelf = { y: shelfY, height: h, x: w };
this._shelves.push(newShelf);
return { x: 0, y: shelfY };
}
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 e9ea3af..e0dc538 100644
--- a/src/simulator/engine/engines/dynamic/gpu-force-layout-engine.ts
+++ b/src/simulator/engine/engines/dynamic/gpu-force-layout-engine.ts
@@ -311,22 +311,24 @@ export class GPUForceLayoutEngine extends BaseLayoutEngine {
super.terminate();
const gl = this._gl;
- if (gl) {
- gl.deleteBuffer(this._quadBuffer);
- gl.deleteVertexArray(this._quadVAO);
- gl.deleteProgram(this._forceProgram);
- gl.deleteTexture(this._stateTexA);
- gl.deleteTexture(this._stateTexB);
- gl.deleteTexture(this._fixedTex);
- gl.deleteTexture(this._treeDataTexture);
- gl.deleteTexture(this._treeChildrenTexture);
- gl.deleteTexture(this._treeGeometryTexture);
- gl.deleteTexture(this._adjOffsetsTexture);
- gl.deleteTexture(this._adjEdgesTexture);
- gl.deleteFramebuffer(this._fboA);
- gl.deleteFramebuffer(this._fboB);
- gl.getExtension('WEBGL_lose_context')?.loseContext();
+ if (!gl) {
+ return;
}
+
+ gl.deleteBuffer(this._quadBuffer);
+ gl.deleteVertexArray(this._quadVAO);
+ gl.deleteProgram(this._forceProgram);
+ gl.deleteTexture(this._stateTexA);
+ gl.deleteTexture(this._stateTexB);
+ gl.deleteTexture(this._fixedTex);
+ gl.deleteTexture(this._treeDataTexture);
+ gl.deleteTexture(this._treeChildrenTexture);
+ gl.deleteTexture(this._treeGeometryTexture);
+ gl.deleteTexture(this._adjOffsetsTexture);
+ gl.deleteTexture(this._adjEdgesTexture);
+ gl.deleteFramebuffer(this._fboA);
+ gl.deleteFramebuffer(this._fboB);
+ gl.getExtension('WEBGL_lose_context')?.loseContext();
}
reheat() {
diff --git a/src/utils/program.utils.ts b/src/utils/program.utils.ts
index e4530d5..b0ae62d 100644
--- a/src/utils/program.utils.ts
+++ b/src/utils/program.utils.ts
@@ -11,7 +11,7 @@ export const createProgram = (
const program = gl.createProgram();
if (!program) {
- throw new OrbError('Failed to create program.');
+ throw new OrbError('Failed to create GL program.');
}
gl.attachShader(program, vertexShader);
@@ -21,7 +21,7 @@ export const createProgram = (
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const info = gl.getProgramInfoLog(program);
gl.deleteProgram(program);
- throw new OrbError(`Failed to link program: ${info}`);
+ throw new OrbError(`Failed to link GL program: ${info}`);
}
gl.deleteShader(vertexShader);
From e63b74aceb354245dc2c1219ff092b9c48bdf547 Mon Sep 17 00:00:00 2001
From: AlexIchenskiy
Date: Thu, 16 Jul 2026 10:22:19 +0200
Subject: [PATCH 9/9] Chore: Add WebGL example and docs
---
docs/view-default.md | 11 +-
examples/example-webgl-renderer.html | 148 +++++++++++++++++++++++++++
examples/index.html | 8 ++
3 files changed, 166 insertions(+), 1 deletion(-)
create mode 100644 examples/example-webgl-renderer.html
diff --git a/docs/view-default.md b/docs/view-default.md
index 34b7659..f5e64bb 100644
--- a/docs/view-default.md
+++ b/docs/view-default.md
@@ -71,8 +71,9 @@ interface IOrbViewSettings {
radius: number;
}
};
- // For canvas rendering and events
+ // For rendering and events
render: {
+ type: 'canvas' | 'webgl';
devicePixelRatio: number | null;
fps: number;
minZoom: number;
@@ -319,6 +320,7 @@ Each layout type has its own option list you can tweak in order to change the la
- `isSimulatingOnDataUpdate` - Whether to run simulation when graph data is updated. Enabled by default (`true`).
- `isSimulatingOnSettingsUpdate` - Whether to re-run simulation when settings are updated. Enabled by default (`true`).
- `isSimulatingOnUnstick` - Whether to re-run simulation when a node is unstuck. Enabled by default (`true`).
+ - `useGPU` - Runs the force layout on the GPU (WebGL2) using a Barnes-Hut quadtree. Falls back to the CPU engine automatically if WebGL2 is unavailable. Disabled by default (`false`).
- `alpha` - Fine-grained control over the simulation's annealing process.
- `alpha` - Current alpha value. Default `1`.
- `alphaMin` - Minimum alpha threshold below which simulation stops. Default `0.001`.
@@ -352,6 +354,13 @@ Each layout type has its own option list you can tweak in order to change the la
Optional property `render` has several rendering options that you can tweak. Read more about them
on [Styling guide](./styles.md).
+#### Property `render.type`
+
+Selects the rendering backend. `canvas` (default) draws with the HTML5 Canvas API, while `webgl`
+uses a WebGL2 renderer that offloads drawing to the GPU for better performance on large graphs.
+The backend is chosen when the view is created and cannot be changed afterwards — to switch, create
+a new view. See the `example-webgl-renderer.html` example for a live comparison.
+
#### Property `render.devicePixelRatio`
`devicePixelRatio` is useful when dealing with the difference between rendering on a standard
diff --git a/examples/example-webgl-renderer.html b/examples/example-webgl-renderer.html
new file mode 100644
index 0000000..b2b40cd
--- /dev/null
+++ b/examples/example-webgl-renderer.html
@@ -0,0 +1,148 @@
+
+
+
+
+
+ Orb | WebGL renderer & GPU layout
+
+
+
+
+
+
Example 9 - WebGL renderer & GPU layout
+
+ Renders a generated graph and lets you switch between the Canvas and WebGL
+ renderers and the CPU and GPU force-layout engines across different graph
+ sizes. The GPU layout falls back to the CPU automatically if WebGL2 is
+ unavailable.
+
+
+
+
+ Renderer
+
+ Canvas
+ WebGL
+
+
+
+ Layout engine
+
+ CPU
+ GPU
+
+
+
+ Nodes
+
+ 100
+ 500
+ 2000
+ 5000
+ 10000
+ 20000
+
+
+ Regenerate
+
+
+
+
+
+
+
+
diff --git a/examples/index.html b/examples/index.html
index 73edf37..57ff39c 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -77,5 +77,13 @@ Orb Examples
Renders a simple graph with hierarchical layout (tree-like).
+
+
Example 9 - WebGL renderer & GPU layout
+
+ Renders a generated graph and lets you switch between the Canvas and WebGL
+ renderers and the CPU and GPU force-layout engines across different graph
+ sizes.
+
+