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.
npm install @fusefactory/fuse-three-forcegraphcore/Engine.ts)
textures/SimulationBuffers.ts)
textures/StaticAssets.ts)
rendering/GraphScene.ts)
simulation/BasePass.ts)
simulation/passes/:
simulation/ForceSimulation.ts)
typescript
// 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')
`
$3
Each simulation step follows this sequence:
1. Velocity Carry - Initialize velocity buffer with damped previous velocity
2. Force Accumulation - Each enabled pass accumulates forces into velocity
- Read current velocity
- Compute force contribution
- Write to previous velocity buffer
- Swap buffers (ping-pong)
3. Integration - Update positions using accumulated velocities
4. Alpha Decay - Reduce simulation heat over time
Usage
$3
`typescript
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
`
$3
For testing and prototyping, you can generate random graph data:
`typescript
// 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))
`
$3
`typescript
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 2
`
$3
`typescript
// 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
$3
- Format: RGBA Float
- Layout: Grid where each pixel = one node
- Channels: (x, y, z, unused)
- Size: Next power-of-2 square ≥ √nodeCount
$3
- 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:
$3
- 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
$3
- 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
- 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
$3
`typescript
// 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)
})
``