Vectojs graph3d
Use when building or debugging a 3D force-directed graph with @vectojs/graph3d — Graph3D instanced rendering, the GraphLayout contract, VectoForceLayout (in-house Barnes-Hut) vs D3ForceLayout, GraphInteraction hover/select/drag-to-pin, or when a graph layout is slow, unstable, or non-deterministic.From its SKILL.md
npx -y skills add vectojs/vectojs-skills --skill vectojs-graph3dAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
6.0 KB, ~1.4k tokens by cl100k_base, as published. Nobody here has run it
VectoJS Graph3D
@vectojs/graph3d renders a force-directed graph as instanced Three.js
geometry and keeps layout strictly separate from rendering. Requires
@vectojs/three and three alongside it.
Architecture: layout and renderer are decoupled
The renderer is deliberately ignorant of how positions were produced.
import { Graph3D, VectoForceLayout, GraphInteraction } from "@vectojs/graph3d";
const graph = new Graph3D({ nodeRadius: 4 });
graph.setGraphData({ nodes, links }); // rebuilds instanced buffers
const layout = new VectoForceLayout();
layout.setGraph({ nodes, links });
function frame() {
const settled = layout.step(); // advance the simulation
graph.applyPositions(layout.positions); // xyz triplets in node order
if (!settled) requestAnimationFrame(frame);
}
setGraphData()rebuilds GPU resources — instanced buffers are fixed-size, so a changed node/link count means fresh meshes. Styling-only changes to the same topology don't need it.applyPositions(Float32Array)takes xyz triplets in node order. Call it after every layout step that moved something.- Unknown link endpoints throw rather than silently drawing a line to the origin — a wrong id is a bug, not a visual glitch.
Choosing a layout
Both implement the same GraphLayout contract (setGraph, step(iterations?)
returning "settled", positions, and optional pinNode/unpinNode/reheat),
so they are drop-in swappable.
VectoForceLayout | D3ForceLayout | |
|---|---|---|
| Dependencies | none (in-house) | d3-force-3d |
| Algorithm | Barnes-Hut octree N-body, O(N log N)/tick | d3's force simulation |
| Determinism | seeded PRNG, f32 throughout | depends on d3 |
| Measured | 4.2–7.2× faster (Chrome), 5.0–8.3× (Firefox) per tick at 500–5000 nodes; margin widens with N | baseline |
Default to VectoForceLayout. It removes a dependency and is several times
faster; D3ForceLayout remains for parity with an existing d3 tuning.
Tuning VectoForceLayout
Defaults are chosen so linked nodes settle closer than unlinked ones:
linkDistance(30) — spring resting length.linkStrength(0.3) — fraction of overshoot corrected per tick, scaled by alpha.repulsion(300) — positive magnitude (d3 expresses this as negative charge).centerStrength(0.02) — pull toward the origin.velocityDecay(0.6) — per-tick velocity retention, i.e.1 - friction.theta(0.9) — Barnes-Hut opening angle.0= exact O(N²); larger = faster and looser. Raise it before lowering node count.alphaDecay(0.0228) — d3's default, ~300 ticks to cool.
step() returns true once cooled. Call reheat() after a topology or pin
change instead of rebuilding the layout.
Interaction
GraphInteraction wires hover, select, and drag-to-pin against the renderer's
pickNode(raycaster). Drag-to-pin routes through pinNode/unpinNode, which is
why those are part of the layout contract — a pinned node is held by the
simulation, not by the renderer.
graph.getNodePosition(index, target) reads a node's current world position into
a THREE.Vector3 you own (returns null for an out-of-range index).
Common mistakes
- Rebuilding
Graph3Devery frame.setGraphData()is a GPU rebuild; only call it when the node/link count changes. - Stepping the layout inside
render(). Step it in your frame loop, then hand positions to the renderer. Mixing them makes the simulation frame-rate dependent. - Not calling
dispose(). BothGraph3DandGraphInteractionown GPU resources and listeners. - Assuming
positionsis a copy. It's the live buffer; copy it if you need a snapshot. - Reaching for a WASM kernel. Deliberately not built — see below.
Performance notes (measured)
applyPositionsderives the instanced mesh's bounding sphere inline from the positions it already has, rather than callingInstancedMesh.computeBoundingSphere()(which re-reads every instance matrix — it measured at 60–78% of the whole method). Frustum culling stays correct because the sphere expands by each instance's true world radius (nodeRadius × cbrt(val)). Net 2.3–3.2× faster.linkLinessetsfrustumCulled = false(a line set spanning the whole graph is never meaningfully cullable);nodeMeshkeeps culling on.- A Rust/WASM force kernel is deliberately deferred. The JS Barnes-Hut is
already 4–8× over d3; a kernel would need either a bad dependency direction
(graph3d → heavy
@vectojs/core, just to load a wasm URL) or a whole new crate + CI wiring, and the per-frame octree is data-dependent, so bit-identical cross-engine differential testing is materially harder than for the transform/particle kernels. Don't start one without a measurement showing the JS layout is the bottleneck.
Verification
- Layout is deterministic: same input + same seed ⇒ same positions. Assert that rather than a screenshot.
step()eventually returnstrue; a layout that never settles is a tuning bug (usuallyvelocityDecaytoo high orrepulsionfightingcenterStrength).- For frame-time claims use the real-browser harness (see the
hyprland-browser-benchskill) and quote both engines — V8 and SpiderMonkey diverge noticeably on this workload.
What ships with it: 1 file
198 B alongside SKILL.md
agents/
- openai.yaml198 B