Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .storybook/style.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap');

body {
font-family: 'Space Grotesk', sans-serif;
font-optical-sizing: auto;
}

tr:nth-of-type(2n) {
background-color: #1e2326;
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ cosmos.gl v3.0 brings a new rendering engine, async initialization, and several
- **GPU transitions** — point positions, point colors/sizes, and link colors/widths now animate by default (`transitionDuration: 800`, `transitionEasing: TransitionEasing.CubicInOut`). Use `transitionDuration: 0` to keep snap updates.
- **Transition callbacks** — use `onTransitionStart`, `onTransition`, and `onTransitionEnd` to track transition lifecycle and progress.
- **Default point shape** — new `pointDefaultShape` config property lets you set the fallback shape for all points when no per-point shapes are provided. Accepts a `PointShape` enum value (e.g., `PointShape.Star`), a plain number (e.g., `6`), or a numeric string (e.g., `"6"`).
- **Collision force** — new GPU-accelerated collision force keeps points from overlapping, using a spatial-hash grid that scales to large graphs. Enable it with `simulationCollision`, and tune the spacing with `simulationCollisionRadius` (fixed radius vs. size-derived) and `simulationCollisionPadding` (extra gap between points). See the [Collision example](https://cosmosgl.github.io/graph/?path=/story/examples-experiments--collision).
- **Exported defaults** — `defaultConfigValues` is now part of the public API.
- **Optimized hover detection** — skips GPU work when the mouse hasn't moved.

Expand Down
139 changes: 139 additions & 0 deletions history/2026/2026-06-13-collision-force.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Spatial-hash collision force

**Date:** 2026-06-13
**Commits:** `6cb1b48`, `566bcba`, `6e41a8a`, `ad860ec`, `8284883`, `9c852f7`, `94dcfd3`

## Why

To make graph visualizations clearer. When point size carries meaning (degree, a
metric, importance), overlapping nodes become illegible — you can't see or click an
individual point. cosmos.gl had no force that resolves overlap: Link, Many-Body,
Gravity, Centering, and Cluster were all there, but Many-Body repulsion acts on point
*centers* and ignores radius, so it can't keep sized points apart. This adds the
missing piece — the same overlap-resolution `d3-force` provides via `forceCollide`,
which the cosmos.gl layout was modeled on.

## What changed

A new GPU force module, `src/modules/ForceCollision/`, that pushes overlapping points
apart. Rather than naive O(n²) pair checks, it builds a spatial-hash grid each tick and
resolves each point against its 3×3 cell neighborhood — staying in line with cosmos.gl's
"everything on the GPU, hundreds of thousands of points" goal.

Module layout:
- `index.ts` — the `ForceCollision` class (`CoreModule` subclass): resource allocation,
program setup, and the per-tick `run()`.
- `build-grid.vert` / `build-grid.frag` — bins each point into a grid cell (point-list
draw, additive blend); each cell accumulates `(sumX, sumY, sumSize, count)`.
- `force-collision-spatial.frag` — fullscreen pass that reads the grid and writes the
per-point collision velocity.

## Config

Three properties (interface in `src/config.ts`, defaults in `src/variables.ts`,
`defaultConfigValues`):

| Property | Meaning | Default | Notes |
|---|---|---|---|
| `simulationCollision` | Force strength; `0` disables it (and skips all GPU work / allocation). | `0` | Story demos use ~0.2–1.0. |
| `simulationCollisionRadius` | Collision radius. `0` and `undefined` are aliases — both derive it per-point as `size * 0.5`; a positive value sets a fixed radius for all points. | `undefined` | Use a fixed value to decouple physics from visual size. |
| `simulationCollisionPadding` | Extra room added to every radius, so neighbors keep a `2 × padding` gap instead of just touching. | `0` | Composes with both derived and fixed radius. |

## How it runs each tick (GPU pipeline)

`run()` (`src/modules/ForceCollision/index.ts:256`) is two-phase, repeated over
`GRID_OFFSETS` — 4 half-cell offsets (`[0,0],[0.5,0],[0,0.5],[0.5,0.5]`) that catch
collisions straddling cell boundaries:

1. **Build:** for each offset, a point-list draw bins every point into one grid cell with
**additive blending** (`blend: 'one'/'one'`), accumulating position-sum, size-sum, and
count. Each offset writes its own grid framebuffer (4 separate FBOs allocated in
`create()`).
2. **Resolve:** a single render pass into `points.velocityFbo` (cleared once), with the
fullscreen force shader drawn 4 times — once per offset grid — accumulating additively.
Each point reads the cell *averages* in its 3×3 neighborhood, computes a push-apart
velocity from the overlap, and the integrator applies it via the usual
`swapFbo → run → updatePosition` dance.

Grid sizing: `cellSize = max(effectiveRadius, 8)` and
`gridTextureSize = clamp(ceil(spaceSize / cellSize), 32, 512)`, then `cellSize` is
recomputed to divide `spaceSize` evenly. The 512 cap bounds grid memory regardless of
space size.

Shaders are **GLSL ES 3.0** (`#version 300 es`), imported with `?raw`, mirroring the
luma.gl `ForceManyBody` module. The build vertex shader samples the positions/size
textures (vertex-shader texture reads are required and supported here).

## Simulation integration (exact wiring)

All in `src/index.ts`:
- **Construction** — created alongside the other forces when `enableSimulation`.
- **Run + lazy init** — gated on `if (simulationCollision)`. On first use (or
after invalidation) it calls `create()` + `initPrograms()` and sets `isForceCollisionReady`.
- **Ordering matters:** collision runs **after** gravity, many-body, links, and clusters
. Running it before the attraction forces let springs/clusters re-create overlap
in the same tick, producing a standing oscillation. Keep it last.
- **Destroy**.
- **Invalidation** of `isForceCollisionReady` (forces a rebuild on next run):
— on point-size / position / many-body data changes (`applyPendingChanges`).
— in `updateStateFromConfig`: on `simulationCollisionRadius` /
`simulationCollisionPadding` change, and — in derived-radius mode — on
`pointDefaultSize` change (size texture + cell size depend on point sizes).

### Lazy allocation (zero-cost when off)

`isForceCollisionReady` (`src/index.ts`) is the whole state machine: GPU resources
(4 grid FBOs, size texture, compiled programs) are allocated **lazily on first run**, so a
graph that never sets `simulationCollision > 0` pays no memory or compile cost. Anything
that changes the inputs sets the flag `false`; the next collision tick rebuilds. If you
add a config/data path that affects collision sizing, add an invalidation there too.

## Stability & correctness details

- **Per-pass correction cap** — each pass clamps its output to ~10% of the point's
collision radius (~40%/frame across 4 passes), so deep overlaps resolve by relaxation
over several frames instead of overshooting and ping-ponging in dense regions.
- **Density damping** — force is scaled down when a point has many neighbors, further
reducing jitter in dense clusters.
- **Border clamping** — the force pass clamps a point's own cell coords to the grid
bounds, matching `build-grid.vert`. Without it, a point that drifts >1 cell outside the
space sees an all-out-of-bounds neighborhood and loses collision response near edges
(fixed in `8284883`).
- **Large-graph safety** — max point size is computed by **looping** over `data.pointSizes`,
not `Math.max(...Array.from(...))`; spreading a 50K+ typed array as call args throws a
`RangeError` before collision even initializes (fixed in `8284883`).

## Tuning guidance

- **Link distance must clear the collision radii.** If `simulationLinkDistance` is smaller
than the combined radii of linked points, springs pull them inside each other and
collision can't win — you get an unresolvable pile. The Collision demo uses
`linkDistance: 50` for sizes up to ~30.
- **Jitter** is reduced by lower `simulationFriction` and shorter `simulationDecay` (less
residual energy), and by the force ordering / correction cap above.
- **Density** = cost. More points per cell (smaller `spaceSize`, larger points) means more
work per tick.

## Examples & docs

- **Collision** (`src/stories/forces/collision.ts`, *Examples/Forces*): a 6-cluster network
of ~600 points, sized by degree and linked sparsely, that the collision force spreads into
a readable layout. Seed positions are symmetric around the space center and the view is
framed up front, so start-up isn't misread as drift. A new *Examples/Forces* group was
introduced and the existing Clustering stories moved under it (*Examples/Forces/Clustering*).
- **Collision Stress Test** (`src/stories/forces/collision-stress-test.ts`, *Examples/Forces*,
commit `9c852f7`): 50,000 points seeded with heavy overlap in a dense disc, repulsion off,
gentle gravity to keep them packed so collision keeps working, `showFPSMonitor: true` to
read the cost under load. Use this to gauge collision performance at scale.
- Docs: `simulationCollision` / `simulationCollisionRadius` / `simulationCollisionPadding`
documented in the Configuration docs (ranges + defaults), Collision listed among the
simulation forces, and the example linked from the README.

## Known limitations / future work

- **Centroid-based resolution.** A point reacts to the *average* of each neighboring cell,
not to individual neighbors — cheap and scalable, but the source of residual jitter in
dense areas. The bigger quality jump (if needed) is exact pairwise resolution: store point
indices per cell and iterate real neighbors.
- The per-pass correction cap trades convergence speed for smoothness; raising it resolves
faster but reintroduces overshoot.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cosmos.gl/graph",
"version": "3.0.0",
"version": "3.1.0",
"description": "GPU-based force graph layout and rendering",
"jsdelivr": "dist/index.min.js",
"main": "dist/index.js",
Expand Down
21 changes: 21 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,27 @@ export interface GraphConfigInterface {
* Default value: `0.1`
*/
simulationCluster: number;
/**
* Collision force coefficient. When set to a value greater than 0,
* points will push each other apart when they overlap.
* Uses a spatial-hash grid, so it scales better than naive O(n²) collision.
* Default value: `0`
*/
simulationCollision: number;
/**
* Collision radius. When set to undefined or 0, the collision radius is derived from
* point sizes (half of the point size). When set to a positive value,
* all points use this fixed collision radius.
* Default value: `undefined`
*/
simulationCollisionRadius: number | undefined;
/**
* Extra padding added to each point's collision radius, in space units.
* Without padding, points settle just touching; with padding, two points
* keep a gap of twice this value between their visual edges.
* Default value: `0`
*/
simulationCollisionPadding: number;

/**
* Callback function that will be called when the simulation starts.
Expand Down
49 changes: 48 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { webgl2Adapter } from '@luma.gl/webgl'
import { applyConfig, createDefaultConfig, resetConfigToDefaults, GraphConfigInterface, type GraphConfig } from '@/graph/config'
import { getRgbaColor, getMaxPointSize, readPixels, extractIndicesFromPixels, sanitizeHtml } from '@/graph/helper'
import { ForceCenter } from '@/graph/modules/ForceCenter'
import { ForceCollision } from '@/graph/modules/ForceCollision'
import { ForceGravity } from '@/graph/modules/ForceGravity'
import { ForceLink, LinkDirection } from '@/graph/modules/ForceLink'
import { ForceManyBody } from '@/graph/modules/ForceManyBody'
Expand Down Expand Up @@ -75,6 +76,7 @@ export class Graph {
private forceLinkIncoming: ForceLink | undefined
private forceLinkOutgoing: ForceLink | undefined
private forceMouse: ForceMouse | undefined
private forceCollision: ForceCollision | undefined
private clusters: Clusters | undefined
private zoomInstance = new Zoom(this.store, this.config)
private transition = new Transition(this.config)
Expand Down Expand Up @@ -128,6 +130,11 @@ export class Graph {
private isForceCenterUpdateNeeded = false
private isPointImageSizesUpdateNeeded = false

// Whether the collision force's GPU resources (grid/size textures, programs)
// are allocated and match the current data. Allocated lazily the first time
// collision runs, so a graph that never enables it pays no memory cost.
private isForceCollisionReady = false

private _isDestroyed = false

/**
Expand Down Expand Up @@ -351,6 +358,7 @@ export class Graph {
this.forceLinkIncoming = new ForceLink(device, this.config, this.store, this.graph, this.points)
this.forceLinkOutgoing = new ForceLink(device, this.config, this.store, this.graph, this.points)
this.forceMouse = new ForceMouse(device, this.config, this.store, this.graph, this.points)
this.forceCollision = new ForceCollision(device, this.config, this.store, this.graph, this.points)
}
this.clusters = new Clusters(device, this.config, this.store, this.graph, this.points)

Expand Down Expand Up @@ -1394,6 +1402,7 @@ export class Graph {
this.forceLinkIncoming?.destroy()
this.forceLinkOutgoing?.destroy()
this.forceMouse?.destroy()
this.forceCollision?.destroy()

if (this.device) {
// Only clear and destroy the device if Graph owns it
Expand Down Expand Up @@ -1448,6 +1457,10 @@ export class Graph {
if (this.isLinkArrowUpdateNeeded) this.lines.updateArrow()

if (this.isForceManyBodyUpdateNeeded) this.forceManyBody?.create()
// Collision grid/size textures depend on point count and sizes. Mark them
// stale so they're rebuilt lazily the next time the collision force runs,
// rather than reallocating here while collision may be disabled.
if (this.isForceManyBodyUpdateNeeded || this.isPointSizeUpdateNeeded) this.isForceCollisionReady = false
if (this.isForceLinkUpdateNeeded) {
this.forceLinkIncoming?.create(LinkDirection.INCOMING)
this.forceLinkOutgoing?.create(LinkDirection.OUTGOING)
Expand Down Expand Up @@ -1590,6 +1603,17 @@ export class Graph {
if (prevConfig.highlightedLinkIndices !== this.config.highlightedLinkIndices) {
this.lines?.updateLinkStatus()
}
// The collision grid's cell size is derived from the collision radius and
// padding, so a change to either requires rebuilding the grid textures.
// In derived-radius mode (radius 0/undefined) the radius — and the size
// texture — come from point sizes, so a pointDefaultSize change must also
// invalidate the collision resources.
if (prevConfig.simulationCollisionRadius !== this.config.simulationCollisionRadius ||
prevConfig.simulationCollisionPadding !== this.config.simulationCollisionPadding ||
((this.config.simulationCollisionRadius === undefined || this.config.simulationCollisionRadius === 0) &&
prevConfig.pointDefaultSize !== this.config.pointDefaultSize)) {
this.isForceCollisionReady = false
}
if (prevConfig.pixelRatio !== this.config.pixelRatio) {
// Update device's canvas context useDevicePixels
if (this.device?.canvasContext) {
Expand Down Expand Up @@ -1743,7 +1767,7 @@ export class Graph {
* to respect pause/unpause state.
*/
private runSimulationStep (forceExecution = false): void {
const { config: { simulationGravity, simulationCenter, enableSimulation }, store: { isSimulationRunning } } = this
const { config: { simulationGravity, simulationCenter, simulationCollision, enableSimulation }, store: { isSimulationRunning } } = this

if (!enableSimulation) return

Expand Down Expand Up @@ -1798,6 +1822,23 @@ export class Graph {
this.points?.updatePosition()
}

// Collision runs after the attraction forces (links, clusters) so it
// corrects the overlap they introduce within the same tick, instead of
// lagging one frame behind and oscillating against them.
if (simulationCollision) {
// Lazily allocate the collision GPU resources on first use (or after a
// data change marked them stale), so a graph that never enables
// collision never pays the grid/size-texture memory cost.
if (!this.isForceCollisionReady) {
this.forceCollision?.create()
this.forceCollision?.initPrograms()
this.isForceCollisionReady = true
}
this.points?.swapFbo()
this.forceCollision?.run()
this.points?.updatePosition()
}

// Alpha decay and progress
this.store.alpha += this.store.addAlpha(this.config.simulationDecay)
if (this.isRightClickMouse && this.config.enableRightClickRepulsion) {
Expand Down Expand Up @@ -1826,6 +1867,7 @@ export class Graph {
this.forceLinkIncoming?.initPrograms()
this.forceLinkOutgoing?.initPrograms()
this.forceMouse?.initPrograms()
// ForceCollision programs are built lazily on first use (see runSimulationStep)
this.clusters.initPrograms()
}

Expand All @@ -1838,6 +1880,7 @@ export class Graph {
this.forceLinkIncoming ||= new ForceLink(this.device, this.config, this.store, this.graph, this.points)
this.forceLinkOutgoing ||= new ForceLink(this.device, this.config, this.store, this.graph, this.points)
this.forceMouse ||= new ForceMouse(this.device, this.config, this.store, this.graph, this.points)
this.forceCollision ||= new ForceCollision(this.device, this.config, this.store, this.graph, this.points)
}

private destroySimulationModules (): void {
Expand All @@ -1853,6 +1896,10 @@ export class Graph {
this.forceLinkOutgoing = undefined
this.forceMouse?.destroy()
this.forceMouse = undefined
this.forceCollision?.destroy()
this.forceCollision = undefined
// Force lazy re-allocation if collision is re-enabled on a new instance.
this.isForceCollisionReady = false
this.points?.destroySimulationResources()
}

Expand Down
11 changes: 11 additions & 0 deletions src/modules/ForceCollision/build-grid.frag
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#version 300 es
precision highp float;

in vec4 cellData;
out vec4 fragColor;

void main() {
// Output accumulated cell data (blended additively)
// xy = sum of positions, z = sum of sizes, w = count
fragColor = cellData;
}
Loading