@plcdnl/fuse-three-forcegraph
v0.0.2
Published
A high-performance GPU-accelerated force-directed graph visualization library built with Three.js. Features a modular pass-based architecture for flexible and extensible force simulations.
Readme
Fuse Three Force Graph
A high-performance GPU-accelerated force-directed graph visualization library built with Three.js. Features a modular pass-based architecture for flexible and extensible force simulations.
Features
- GPU-Accelerated: All force calculations run on the GPU using WebGL compute shaders
- Modular Pass Architecture: Flexible system for composing and customizing force behaviors
- Ping-Pong Rendering: Efficient double-buffering for position and velocity updates
- Interactive Controls: Built-in camera controls, node dragging, and hover interactions
- Extensible: Easy to add custom force passes and visual effects
Architecture
Core Components
Engine (core/Engine.ts)
The main orchestrator that owns and coordinates all components:
- Manages shared GPU buffers (SimulationBuffers, StaticAssets, PickBuffer)
- Coordinates GraphStore, GraphScene, and ForceSimulation
- Handles the render loop and user interactions
SimulationBuffers (textures/SimulationBuffers.ts)
Manages dynamic render targets updated by force simulation:
- Position buffers (current, previous, original) for ping-pong rendering
- Velocity buffers for force accumulation
- Automatically sizes textures based on node count
StaticAssets (textures/StaticAssets.ts)
Manages read-only GPU textures:
- Node radii and colors
- Link indices and properties
- Created once at initialization, updated only on mode changes
GraphScene (rendering/GraphScene.ts)
Manages the 3D scene and visual rendering:
- Node and link renderers
- Camera controls
- Visual mode application
Force Simulation
The simulation uses a pass-based architecture where each force type is implemented as an independent pass:
BasePass (simulation/BasePass.ts)
Abstract base class for all force passes. Provides:
- Material management
- Uniform updates
- Enable/disable control
- Render execution
Built-in Force Passes
Located in simulation/passes/:
- VelocityCarryPass - Applies damping to previous velocity
- CollisionPass - Prevents node overlap
- ManyBodyPass - Charge repulsion between all nodes
- GravityPass - Pulls nodes toward center
- LinkPass - Spring forces between connected nodes
- EmbeddingsPass - Elastic pull toward original positions
- DragPass - Interactive node dragging
- IntegratePass - Updates positions from velocities
ForceSimulation (simulation/ForceSimulation.ts)
Manages and executes force passes:
// Add custom force pass
simulation.addPass('myForce', new MyCustomPass(config))
// Remove a pass
simulation.removePass('collision')
// Enable/disable a pass
simulation.setPassEnabled('gravity', false)
// Get a pass for configuration
const gravityPass = simulation.getPass('gravity')Execution Pipeline
Each simulation step follows this sequence:
- Velocity Carry - Initialize velocity buffer with damped previous velocity
- Force Accumulation - Each enabled pass accumulates forces into velocity
- Read current velocity
- Compute force contribution
- Write to previous velocity buffer
- Swap buffers (ping-pong)
- Integration - Update positions using accumulated velocities
- Alpha Decay - Reduce simulation heat over time
Usage
Basic Setup
import { Engine } from './core/Engine'
// Create engine with a canvas element
const engine = new Engine(canvas, {
width: window.innerWidth,
height: window.innerHeight,
backgroundColor: '#000000'
})
// Load graph data
const graphData = {
nodes: [
{ id: '1', x: 0, y: 0, z: 0 },
{ id: '2', x: 10, y: 10, z: 0 }
],
links: [
{ source: '1', target: '2' }
]
}
engine.setData(graphData)
// Start simulation and rendering
engine.start()
GRAPH.styleRegistry.setNodeStyles({
'root': { color: 0xE53E3E, size: 55 },
'series': { color: 0x38A169, size: 33 },
'artwork': { color: 0x3182CE, size: 22 },
})
// Get simulation config - direct access, changes take effect immediately
const simulation = engine.getSimulation()
const config = simulation.config
// Create Tweakpane
pane = new Pane()
// Bind simulation parameters - changes to config work directly, no sync needed
const simFolder = pane.addFolder({ title: 'Simulation' })
simFolder.addBinding(config, 'alpha', { min: 0, max: 1 })
simFolder.addBinding(config, 'alphaDecay', { min: 0, max: 0.1 })
simFolder.addBinding(config, 'damping', { min: 0, max: 1 })
// Many-body force, check uniforms that can be added in binding...
const manyBodyFolder = pane.addFolder({ title: 'Many-Body Force' })
manyBodyFolder.addBinding(config, 'enableManyBody')
manyBodyFolder.addBinding(config, 'manyBodyStrength', { min: 0, max: 100 })
// Set up attractors - each pulls specific categories
simulation.setAttractors([
{
id: 'center',
position: { x: 0, y: 0.0, z: 0 },
categories: ['root'],
strength: 55.
},])
// Adjust global attractor strength
simulation.config.attractorStrength = 0.03
Generating Random Data
For testing and prototyping, you can generate random graph data:
// Utility function to create random nodes and links
const createRandomData = (nodeCount: number = 10, linkCount: number = 15) => {
const groups = ['root', 'series', 'artwork', 'character', 'location']
// Generate random nodes
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
id: (i + 1).toString(),
group: groups[Math.floor(Math.random() * groups.length)],
x: (Math.random() - 0.5) * 2,
y: (Math.random() - 0.5) * 2,
z: (Math.random() - 0.5) * 2
}))
// Generate random links
const links = Array.from({ length: linkCount }, () => {
const sourceId = Math.floor(Math.random() * nodeCount) + 1
let targetId = Math.floor(Math.random() * nodeCount) + 1
// Ensure source and target are different
while (targetId === sourceId) {
targetId = Math.floor(Math.random() * nodeCount) + 1
}
return {
source: sourceId.toString(),
target: targetId.toString()
}
})
return { nodes, links }
}
// Use random data
engine.setData(createRandomData(100, 70))Custom Force Pass
import { BasePass, type PassContext } from './simulation/BasePass'
class CustomForcePass extends BasePass {
private strength: number = 1.0
getName(): string {
return 'CustomForce'
}
initMaterial(context: PassContext): void {
this.material = this.createMaterial(
vertexShader,
fragmentShader,
{
uPositionsTexture: { value: null },
uVelocityTexture: { value: null },
uStrength: { value: this.strength },
uAlpha: { value: 1.0 }
}
)
}
updateUniforms(context: PassContext): void {
if (!this.material) return
this.material.uniforms.uPositionsTexture.value =
context.simBuffers.getCurrentPositionTexture()
this.material.uniforms.uVelocityTexture.value =
context.simBuffers.getCurrentVelocityTexture()
this.material.uniforms.uAlpha.value = context.alpha
this.material.uniforms.uStrength.value = this.strength
}
setStrength(strength: number): void {
this.strength = strength
}
}
// Add to simulation
const customPass = new CustomForcePass()
customPass.initMaterial(context)
forceSimulation.addPass('custom', customPass, 2) // position 2Configuration
// Update force configuration
engine.getSimulation().updateConfig({
manyBodyStrength: 100,
enableCollision: true,
collisionRadius: 8.0,
gravity: 1.2,
damping: 0.95,
alpha: 1.0
})
// Interactive node dragging
engine.getInteractionManager().on('dragStart', ({ nodeId }) => {
console.log('Dragging node:', nodeId)
})
// Node picking
const nodeId = engine.pickNode(mouseX, mouseY)Project Structure
fuse-three-forcegraph/
├── assets/
│ └── glsl/ # GLSL shaders for force simulation
│ ├── force-sim/ # Force compute shaders
│ ├── lines/ # Link rendering shaders
│ └── points/ # Node rendering shaders
├── audio/ # Audio integration (RNBO)
├── controls/ # Input handling and interactions
│ ├── InteractionManager.ts
│ ├── InputProcessor.ts
│ └── handlers/ # Click, drag, hover handlers
├── core/ # Core engine components
│ ├── Engine.ts # Main orchestrator
│ ├── EventEmitter.ts # Event system
│ └── GraphStore.ts # Graph data management
├── rendering/ # Visual rendering
│ ├── GraphScene.ts # Scene management
│ ├── CameraController.ts
│ ├── nodes/ # Node rendering
│ └── links/ # Link rendering
├── simulation/ # Force simulation
│ ├── BasePass.ts # Pass base class
│ ├── ForceSimulation.ts # Pass manager
│ └── passes/ # Individual force passes
├── textures/ # GPU buffer management
│ ├── SimulationBuffers.ts # Dynamic buffers
│ ├── StaticAssets.ts # Static textures
│ └── PickBuffer.ts # GPU picking
├── types/ # TypeScript definitions
└── ui/ # UI components (tooltips, etc.)GPU Texture Layout
Position/Velocity Buffers
- Format:
RGBA Float - Layout: Grid where each pixel = one node
- Channels:
(x, y, z, unused) - Size: Next power-of-2 square ≥ √nodeCount
Static Assets
- Radii:
Red Float(1 channel) - Colors:
RGBA Float(4 channels) - Link Indices:
RGBA Float(source_x, source_y, target_x, target_y)
Performance Considerations
- All position/velocity data stays on GPU
- Force computations use fragment shaders (parallel)
- Ping-pong rendering avoids read-after-write hazards
- Static assets minimize data transfer
- Geometry uses instancing for efficient rendering
Interaction Model
The library implements a three-tiered interaction system for progressive engagement:
1. Hover
- Trigger: Mouse cursor enters node boundary
- Purpose: Lightweight preview and visual feedback
- Response:
- Node highlight/glow effect
- Cursor change
- Optional tooltip display
- No layout disruption
- Use Case: Quick scanning and exploration
2. Pop (Dwell/Long Hover)
- Trigger: Cursor remains over node for defined duration (e.g., 500ms)
- Purpose: Detailed information display without commitment
- Response:
- Expanded tooltip/info card
- Highlight connected nodes and edges
- Subtle camera focus adjustment
- Audio feedback (optional)
- Use Case: Examining node details and immediate connections
3. Click
- Trigger: Primary mouse button click on node
- Purpose: Full interaction and state change
- Response:
- Node selection/deselection
- Full graph filtering (show only connected components)
- Panel/sidebar updates
- Deep-dive views
- State persistence
- Use Case: Focused analysis and permanent selection
Interaction Pipeline
// Hover
interactionManager.on('hover', ({ node, position }) => {
// Immediate visual feedback
graphScene.highlightNode(node.id, 'hover')
})
// Pop (triggered after dwell time)
interactionManager.on('pop', ({ node, dwellTime }) => {
// Show detailed tooltip
tooltipManager.showExpanded(node)
// Highlight neighborhood
graphScene.highlightNeighborhood(node.id)
})
// Click
interactionManager.on('click', ({ node }) => {
// Full selection
graphStore.selectNode(node.id)
// Filter graph
graphScene.filterToConnected(node.id)
})This progressive disclosure pattern prevents overwhelming users while enabling deep exploration when needed.
Dependencies
- three.js - 3D rendering engine
- camera-controls - Camera manipulation
- gsap - Animation and transitions
