UNPKG

d3-force-webgpu

Version:

Force-directed graph layout using velocity Verlet integration with WebGPU acceleration.

802 lines (694 loc) 23.1 kB
// GPU-Accelerated Force Simulation // Drop-in replacement for forceSimulation with WebGPU acceleration import { dispatch } from "d3-dispatch"; import { timer } from "d3-timer"; import { initWebGPU, createComputePipeline } from "./device.js"; import { NodeBuffers, LinkBuffers, SimulationParamsBuffer } from "./buffers.js"; import { manyBodyShader, linkShader, integrateShader, collisionShader, forceXShader, forceYShader, forceRadialShader } from "./shaders.js"; import lcg from "../lcg.js"; const WORKGROUP_SIZE = 256; export function x(d) { return d.x; } export function y(d) { return d.y; } var initialRadius = 10, initialAngle = Math.PI * (3 - Math.sqrt(5)); export default function (nodes) { var simulation, alpha = 1, alphaMin = 0.001, alphaDecay = 1 - Math.pow(alphaMin, 1 / 300), alphaTarget = 0, velocityDecay = 0.6, forces = new Map(), stepper = timer(step), event = dispatch("tick", "end"), random = lcg(); // GPU resources var gpuInitialized = false, gpuDevice = null, nodeBuffers = null, paramsBuffer = null, pipelines = {}, bindGroups = {}, gpuReadyPromise = null, gpuReadyResolve = null, gpuLoopRunning = false; // GPU compute loop running flag // Force-specific GPU resources var linkBuffers = null, linkData = { links: [], distances: [], strengths: [], bias: [] }; // Force configurations (mirroring CPU forces) var manyBodyConfig = { enabled: false, strength: -30, theta: 0.9, distanceMin: 1, distanceMax: Infinity, }; var centerConfig = { enabled: false, x: 0, y: 0, strength: 1, }; var collisionConfig = { enabled: false, radius: 5, strength: 1, iterations: 1, }; var forceXConfig = { enabled: false, x: 0, strength: 0.1, }; var forceYConfig = { enabled: false, y: 0, strength: 0.1, }; var radialConfig = { enabled: false, x: 0, y: 0, radius: 100, strength: 0.1, }; if (nodes == null) nodes = []; // Create promise for gpuReady() gpuReadyPromise = new Promise((resolve) => { gpuReadyResolve = resolve; }); async function initGPU() { if (gpuInitialized) return true; try { gpuDevice = await initWebGPU(); await createPipelines(); gpuInitialized = true; return true; } catch (e) { console.warn("WebGPU initialization failed, falling back to CPU:", e); return false; } } async function createPipelines() { // Many-body force pipeline pipelines.manyBody = await createComputePipeline( gpuDevice, manyBodyShader, "main" ); // Link force pipeline pipelines.linkForce = await createComputePipeline( gpuDevice, linkShader, "main" ); // Integration pipeline pipelines.integrate = await createComputePipeline( gpuDevice, integrateShader, "main" ); // Collision pipeline pipelines.collision = await createComputePipeline( gpuDevice, collisionShader, "main" ); // ForceX pipeline pipelines.forceX = await createComputePipeline( gpuDevice, forceXShader, "main" ); // ForceY pipeline pipelines.forceY = await createComputePipeline( gpuDevice, forceYShader, "main" ); // Radial force pipeline pipelines.radial = await createComputePipeline( gpuDevice, forceRadialShader, "main" ); } function createBuffers() { if (!gpuDevice || nodes.length === 0) return; // Destroy old buffers if (nodeBuffers) nodeBuffers.destroy(); if (paramsBuffer) paramsBuffer.destroy(); if (linkBuffers) linkBuffers.destroy(); nodeBuffers = new NodeBuffers(gpuDevice, nodes.length); paramsBuffer = new SimulationParamsBuffer(gpuDevice); // Create link buffers if we have links if (linkData.links.length > 0) { linkBuffers = new LinkBuffers(gpuDevice, linkData.links.length); } createBindGroups(); } function createBindGroups() { if (!gpuDevice || !nodeBuffers) return; // Many-body bind group bindGroups.manyBody = gpuDevice.createBindGroup({ layout: pipelines.manyBody.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: nodeBuffers.storageBuffer } }, { binding: 1, resource: { buffer: paramsBuffer.storageBuffer } }, ], }); // Integration bind group bindGroups.integrate = gpuDevice.createBindGroup({ layout: pipelines.integrate.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: nodeBuffers.storageBuffer } }, { binding: 1, resource: { buffer: paramsBuffer.storageBuffer } }, ], }); // Collision bind group bindGroups.collision = gpuDevice.createBindGroup({ layout: pipelines.collision.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: nodeBuffers.storageBuffer } }, { binding: 1, resource: { buffer: paramsBuffer.storageBuffer } }, ], }); // Link bind group (if links exist) if (linkBuffers) { bindGroups.link = gpuDevice.createBindGroup({ layout: pipelines.linkForce.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: nodeBuffers.storageBuffer } }, { binding: 1, resource: { buffer: linkBuffers.storageBuffer } }, { binding: 2, resource: { buffer: paramsBuffer.storageBuffer } }, ], }); } // ForceX bind group bindGroups.forceX = gpuDevice.createBindGroup({ layout: pipelines.forceX.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: nodeBuffers.storageBuffer } }, { binding: 1, resource: { buffer: paramsBuffer.storageBuffer } }, ], }); // ForceY bind group bindGroups.forceY = gpuDevice.createBindGroup({ layout: pipelines.forceY.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: nodeBuffers.storageBuffer } }, { binding: 1, resource: { buffer: paramsBuffer.storageBuffer } }, ], }); // Radial force bind group bindGroups.radial = gpuDevice.createBindGroup({ layout: pipelines.radial.getBindGroupLayout(0), entries: [ { binding: 0, resource: { buffer: nodeBuffers.storageBuffer } }, { binding: 1, resource: { buffer: paramsBuffer.storageBuffer } }, ], }); } function uploadToGPU() { if (!nodeBuffers) return; // Set per-node strengths for many-body force for (let i = 0; i < nodes.length; i++) { nodes[i]._strength = manyBodyConfig.strength; nodes[i].radius = nodes[i].radius || collisionConfig.radius; } nodeBuffers.uploadNodes(nodes); paramsBuffer.update({ alpha: alpha, velocityDecay: velocityDecay, nodeCount: nodes.length, linkCount: linkData.links.length, centerX: centerConfig.x, centerY: centerConfig.y, centerStrength: centerConfig.strength, theta2: manyBodyConfig.theta * manyBodyConfig.theta, distanceMin2: manyBodyConfig.distanceMin * manyBodyConfig.distanceMin, distanceMax2: manyBodyConfig.distanceMax * manyBodyConfig.distanceMax, iterations: 1, collisionRadius: collisionConfig.radius, collisionStrength: collisionConfig.strength, collisionIterations: collisionConfig.iterations, forceXTarget: forceXConfig.x, forceXStrength: forceXConfig.enabled ? forceXConfig.strength : 0, forceYTarget: forceYConfig.y, forceYStrength: forceYConfig.enabled ? forceYConfig.strength : 0, radialX: radialConfig.x, radialY: radialConfig.y, radialRadius: radialConfig.radius, radialStrength: radialConfig.enabled ? radialConfig.strength : 0, }); if (linkBuffers && linkData.links.length > 0) { linkBuffers.uploadLinks( linkData.links, linkData.distances, linkData.strengths, linkData.bias ); } } // Run a single GPU tick (compute + readback) async function runGPUTick() { alpha += (alphaTarget - alpha) * alphaDecay; uploadToGPU(); const workgroups = Math.ceil(nodes.length / WORKGROUP_SIZE); const commandEncoder = gpuDevice.createCommandEncoder(); // Apply forces in order const passEncoder = commandEncoder.beginComputePass(); // 1. Many-body force if (manyBodyConfig.enabled && bindGroups.manyBody) { passEncoder.setPipeline(pipelines.manyBody); passEncoder.setBindGroup(0, bindGroups.manyBody); passEncoder.dispatchWorkgroups(workgroups); } // 2. Link force if (linkData.links.length > 0 && bindGroups.link) { passEncoder.setPipeline(pipelines.linkForce); passEncoder.setBindGroup(0, bindGroups.link); passEncoder.dispatchWorkgroups(workgroups); } // 3. Collision force if (collisionConfig.enabled && bindGroups.collision) { for (let ci = 0; ci < collisionConfig.iterations; ci++) { passEncoder.setPipeline(pipelines.collision); passEncoder.setBindGroup(0, bindGroups.collision); passEncoder.dispatchWorkgroups(workgroups); } } // 4. ForceX (x-positioning force) if (forceXConfig.enabled && bindGroups.forceX) { passEncoder.setPipeline(pipelines.forceX); passEncoder.setBindGroup(0, bindGroups.forceX); passEncoder.dispatchWorkgroups(workgroups); } // 5. ForceY (y-positioning force) if (forceYConfig.enabled && bindGroups.forceY) { passEncoder.setPipeline(pipelines.forceY); passEncoder.setBindGroup(0, bindGroups.forceY); passEncoder.dispatchWorkgroups(workgroups); } // 6. Radial force if (radialConfig.enabled && bindGroups.radial) { passEncoder.setPipeline(pipelines.radial); passEncoder.setBindGroup(0, bindGroups.radial); passEncoder.dispatchWorkgroups(workgroups); } // 7. Integration (position update) passEncoder.setPipeline(pipelines.integrate); passEncoder.setBindGroup(0, bindGroups.integrate); passEncoder.dispatchWorkgroups(workgroups); passEncoder.end(); gpuDevice.queue.submit([commandEncoder.finish()]); // Read back results await nodeBuffers.downloadNodes(nodes); // Apply center force on CPU (requires reduction) if (centerConfig.enabled) { applyCenterCPU(); } } // GPU compute loop - runs independently of d3-timer async function gpuComputeLoop() { if (gpuLoopRunning) return; gpuLoopRunning = true; try { while (alpha >= alphaMin && gpuLoopRunning && gpuInitialized) { await runGPUTick(); // Fire tick event after each GPU computation completes event.call("tick", simulation); } if (alpha < alphaMin) { gpuLoopRunning = false; event.call("end", simulation); } } catch (e) { console.error("GPU compute loop failed, falling back to CPU:", e); gpuInitialized = false; gpuLoopRunning = false; } } async function tickGPU(iterations) { if (!gpuInitialized || !nodeBuffers || !gpuDevice) { // Fallback to CPU return tickCPU(iterations); } // GPU mode: the compute loop handles ticks, not the timer // Just start the loop if not already running if (!gpuLoopRunning) { gpuComputeLoop(); } return simulation; } function applyCenterCPU() { let sx = 0, sy = 0; for (let i = 0; i < nodes.length; i++) { sx += nodes[i].x; sy += nodes[i].y; } sx = (sx / nodes.length - centerConfig.x) * centerConfig.strength; sy = (sy / nodes.length - centerConfig.y) * centerConfig.strength; for (let i = 0; i < nodes.length; i++) { nodes[i].x -= sx; nodes[i].y -= sy; } } function tickCPU(iterations) { var i, n = nodes.length, node; if (iterations === undefined) iterations = 1; for (var k = 0; k < iterations; ++k) { alpha += (alphaTarget - alpha) * alphaDecay; forces.forEach(function (force) { force(alpha); }); for (i = 0; i < n; ++i) { node = nodes[i]; if (node.fx == null) node.x += node.vx *= velocityDecay; else (node.x = node.fx), (node.vx = 0); if (node.fy == null) node.y += node.vy *= velocityDecay; else (node.y = node.fy), (node.vy = 0); } } return simulation; } function step() { tick(); // In GPU mode, tick events are fired by the GPU compute loop // In CPU mode, we fire them here if (!gpuInitialized) { event.call("tick", simulation); if (alpha < alphaMin) { stepper.stop(); event.call("end", simulation); } } } function tick(iterations) { if (gpuInitialized) { tickGPU(iterations); } else { tickCPU(iterations); } return simulation; } function initializeNodes() { for (var i = 0, n = nodes.length, node; i < n; ++i) { (node = nodes[i]), (node.index = i); if (node.fx != null) node.x = node.fx; if (node.fy != null) node.y = node.fy; if (isNaN(node.x) || isNaN(node.y)) { var radius = initialRadius * Math.sqrt(0.5 + i), angle = i * initialAngle; node.x = radius * Math.cos(angle); node.y = radius * Math.sin(angle); } if (isNaN(node.vx) || isNaN(node.vy)) { node.vx = node.vy = 0; } } } function initializeForce(force) { if (force.initialize) force.initialize(nodes, random); return force; } initializeNodes(); // Warmup pass to force shader compilation async function warmupGPU() { if (!gpuDevice || !nodeBuffers || nodes.length === 0) return; // Run a single compute pass to trigger shader compilation const workgroups = Math.ceil(nodes.length / WORKGROUP_SIZE); const commandEncoder = gpuDevice.createCommandEncoder(); const passEncoder = commandEncoder.beginComputePass(); // Dispatch each pipeline once to compile shaders if (bindGroups.manyBody) { passEncoder.setPipeline(pipelines.manyBody); passEncoder.setBindGroup(0, bindGroups.manyBody); passEncoder.dispatchWorkgroups(workgroups); } if (bindGroups.collision) { passEncoder.setPipeline(pipelines.collision); passEncoder.setBindGroup(0, bindGroups.collision); passEncoder.dispatchWorkgroups(workgroups); } if (bindGroups.forceX) { passEncoder.setPipeline(pipelines.forceX); passEncoder.setBindGroup(0, bindGroups.forceX); passEncoder.dispatchWorkgroups(workgroups); } if (bindGroups.forceY) { passEncoder.setPipeline(pipelines.forceY); passEncoder.setBindGroup(0, bindGroups.forceY); passEncoder.dispatchWorkgroups(workgroups); } if (bindGroups.radial) { passEncoder.setPipeline(pipelines.radial); passEncoder.setBindGroup(0, bindGroups.radial); passEncoder.dispatchWorkgroups(workgroups); } passEncoder.setPipeline(pipelines.integrate); passEncoder.setBindGroup(0, bindGroups.integrate); passEncoder.dispatchWorkgroups(workgroups); passEncoder.end(); gpuDevice.queue.submit([commandEncoder.finish()]); // Wait for GPU to finish compilation await gpuDevice.queue.onSubmittedWorkDone(); } // Start GPU initialization initGPU().then(async () => { if (gpuInitialized) { createBuffers(); await warmupGPU(); } gpuReadyResolve(gpuInitialized); }); return (simulation = { tick: tick, restart: function () { stepper.restart(step); // Restart GPU compute loop if in GPU mode if (gpuInitialized && !gpuLoopRunning) { gpuComputeLoop(); } return simulation; }, stop: function () { gpuLoopRunning = false; // Stop GPU compute loop return stepper.stop(), simulation; }, nodes: function (_) { if (!arguments.length) return nodes; nodes = _; initializeNodes(); forces.forEach(initializeForce); if (gpuInitialized) createBuffers(); return simulation; }, alpha: function (_) { return arguments.length ? ((alpha = +_), simulation) : alpha; }, alphaMin: function (_) { return arguments.length ? ((alphaMin = +_), simulation) : alphaMin; }, alphaDecay: function (_) { return arguments.length ? ((alphaDecay = +_), simulation) : +alphaDecay; }, alphaTarget: function (_) { return arguments.length ? ((alphaTarget = +_), simulation) : alphaTarget; }, velocityDecay: function (_) { return arguments.length ? ((velocityDecay = 1 - _), simulation) : 1 - velocityDecay; }, randomSource: function (_) { return arguments.length ? ((random = _), forces.forEach(initializeForce), simulation) : random; }, force: function (name, _) { if (arguments.length > 1) { if (_ == null) { forces.delete(name); // Disable GPU force if (name === "charge" || name === "manyBody") { manyBodyConfig.enabled = false; } else if (name === "center") { centerConfig.enabled = false; } else if (name === "collide") { collisionConfig.enabled = false; } else if (name === "link") { linkData = { links: [], distances: [], strengths: [], bias: [] }; if (linkBuffers) { linkBuffers.destroy(); linkBuffers = null; } } else if (name === "x") { forceXConfig.enabled = false; } else if (name === "y") { forceYConfig.enabled = false; } else if (name === "radial") { radialConfig.enabled = false; } } else { forces.set(name, initializeForce(_)); // Configure GPU force based on CPU force settings configureGPUForce(name, _); } return simulation; } return forces.get(name); }, find: function (x, y, radius) { var i = 0, n = nodes.length, dx, dy, d2, node, closest; if (radius == null) radius = Infinity; else radius *= radius; for (i = 0; i < n; ++i) { node = nodes[i]; dx = x - node.x; dy = y - node.y; d2 = dx * dx + dy * dy; if (d2 < radius) (closest = node), (radius = d2); } return closest; }, on: function (name, _) { return arguments.length > 1 ? (event.on(name, _), simulation) : event.on(name); }, // GPU-specific methods isGPUEnabled: function () { return gpuInitialized; }, gpuReady: function () { return gpuReadyPromise; }, async initGPU() { const success = await initGPU(); if (success) createBuffers(); return success; }, }); function configureGPUForce(name, force) { if (name === "charge" || name === "manyBody") { manyBodyConfig.enabled = true; // Extract settings from force if available if (force.strength) { const s = force.strength(); manyBodyConfig.strength = typeof s === "function" ? -30 : s; } if (force.theta) manyBodyConfig.theta = force.theta(); if (force.distanceMin) manyBodyConfig.distanceMin = force.distanceMin(); if (force.distanceMax) manyBodyConfig.distanceMax = force.distanceMax(); } else if (name === "center") { centerConfig.enabled = true; if (force.x) centerConfig.x = force.x(); if (force.y) centerConfig.y = force.y(); if (force.strength) centerConfig.strength = force.strength(); } else if (name === "collide") { collisionConfig.enabled = true; if (force.radius) { const r = force.radius(); collisionConfig.radius = typeof r === "function" ? 5 : r; } if (force.strength) collisionConfig.strength = force.strength(); if (force.iterations) collisionConfig.iterations = force.iterations(); } else if (name === "link") { // Extract link data if (force.links) { const links = force.links(); linkData.links = links; linkData.distances = []; linkData.strengths = []; linkData.bias = []; // Get distances and strengths const distFn = force.distance ? force.distance() : () => 30; const strFn = force.strength ? force.strength() : null; // Count connections per node for bias calculation const count = new Array(nodes.length).fill(0); for (const link of links) { count[link.source.index] = (count[link.source.index] || 0) + 1; count[link.target.index] = (count[link.target.index] || 0) + 1; } for (let i = 0; i < links.length; i++) { const link = links[i]; linkData.distances.push( typeof distFn === "function" ? distFn(link, i, links) : distFn ); if (strFn) { linkData.strengths.push( typeof strFn === "function" ? strFn(link, i, links) : strFn ); } else { // Default strength: 1 / min(count[source], count[target]) linkData.strengths.push( 1 / Math.min( count[link.source.index], count[link.target.index] ) ); } // Bias: count[source] / (count[source] + count[target]) linkData.bias.push( count[link.source.index] / (count[link.source.index] + count[link.target.index]) ); } // Create link buffers if (gpuInitialized && links.length > 0) { if (linkBuffers) linkBuffers.destroy(); linkBuffers = new LinkBuffers(gpuDevice, links.length); createBindGroups(); } } } else if (name === "x") { forceXConfig.enabled = true; if (force.x) { const xVal = force.x(); forceXConfig.x = typeof xVal === "function" ? 0 : xVal; } if (force.strength) { const s = force.strength(); forceXConfig.strength = typeof s === "function" ? 0.1 : s; } } else if (name === "y") { forceYConfig.enabled = true; if (force.y) { const yVal = force.y(); forceYConfig.y = typeof yVal === "function" ? 0 : yVal; } if (force.strength) { const s = force.strength(); forceYConfig.strength = typeof s === "function" ? 0.1 : s; } } else if (name === "radial") { radialConfig.enabled = true; if (force.x) radialConfig.x = force.x(); if (force.y) radialConfig.y = force.y(); if (force.radius) { const r = force.radius(); radialConfig.radius = typeof r === "function" ? 100 : r; } if (force.strength) { const s = force.strength(); radialConfig.strength = typeof s === "function" ? 0.1 : s; } } } }