LearnNewsExamplesServices
Frontmatter
id19295
titleGraphScene draws 100k-node scenes through a foveated LOD
stateClosed
labels
enhancementaiperformance
assigneesneo-opus-ada
createdAtSep 26, 2026, 9:31 PM
updatedAtSep 27, 2026, 10:45 AM
githubUrlhttps://github.com/neomjs/neo/issues/19295
authorneo-opus-ada
commentsCount0
parentIssue10034
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 27, 2026, 10:45 AM

GraphScene draws 100k-node scenes through a foveated LOD

Closed v13.2.0/archive-v13-2-0-chunk-23 enhancementaiperformance
neo-opus-ada
neo-opus-ada commented on Sep 26, 2026, 9:31 PM

Context

  • Operator, 2026-09-26: our graph has 100k+ nodes and 300k+ edges. Clio's drafts show the direction: far away merge nodes and bundle edges, near by resolve them, in our own renderer on OffscreenCanvas + Neo with no external library.
  • D#19151 H3-a, adopted as the renderer shape (#10034's promotion record, issuecomment-5844273421): foveated LOD.
    • far: cluster centroids + inter-cluster bundles;
    • mid: every node + bundles;
    • near: the nearest clusters' own edges, drawn as per-cluster index ranges.
  • The measured lap (probe on 100k nodes / 1M edges / 64 clusters, canvas-worker arm): full draw 28.3 fps; far 117.6, mid 119.9, near 115.9 at 120 Hz. "Edges are the budget and LOD returns it, without ever rebuilding a buffer." The probe is archived outside the repo; the tiers and numbers are in D#19151.
  • The promotion record names this "the engine leaf (filed by Ada)". #19261 extracted Neo.canvas.GraphScene without the LOD, so this is the owed remainder.

The Problem

GraphScene draws every edge at every camera distance. At 100k nodes and 1M edges the frame is fill-bound, the probe's 26–28 fps for the full draw, and a zoomed-out dense graph is unreadable anyway. A scene cannot say which nodes belong together, so there is nothing to aggregate and no per-cluster edge range to draw.

The CPU path is not the constraint. Measured in Node at dev 108b6ccb1c:

scene setScene pick per move structuredClone, typed / plain
100k / 300k 5.0 ms 1.5 ms 0.47 / 14 ms
100k / 1M 10.9 ms 1.3 ms 0.66 / 28 ms

The Architectural Reality

  • src/canvas/GraphScene.mjs: normalizeScene settles positions / colors / sizes / edges / paths into typed arrays. upload builds one node VAO, one edge index buffer, and the path buffers with their beads. render draws edges (LINES), paths (LINE_STRIP), beads and nodes (POINTS), all of them every frame. fit frames the bounding sphere, and camera.dist against the fitted distance is the natural LOD input.
  • Scenes carry no cluster ids. D#19151 H3-e ("zoom into stable-id clusters") places cluster identity in the scene, so the producer or the client layout assigns it and the renderer computes no communities.
  • normalizeScene is the subclass seam (reachable since #19290), and a subclass that derives clusters itself can fill the field there.

The Fix

  1. Scene input: optional clusters, a Uint32Array with one cluster index per node. Without it, GraphScene draws as today.
  2. Derived once per setScene:
    • cluster centroids (mean position), sizes (by member count) and colours (mean member colour);
    • the edges re-ordered so each cluster's intra-cluster edges form one contiguous index range, [start, count] per cluster;
    • inter-cluster bundles: one line between the centroids of each connected cluster pair.
  3. The level follows camera.dist against the fitted distance, with configurable thresholds:
    • far: centroids + bundles;
    • mid: every node + bundles;
    • near: every node + bundles + the intra-cluster ranges of the nearClusters clusters nearest the camera target. A level change changes draw ranges, never buffers.
  4. Paths (the Golden Path ribbon) draw at every level.
  5. getStats reports lod: {level, edgesDrawn}. pick answers -1 at far, where no node is drawn.
  6. A scale harness in examples/component/graphScene: a clustered synthetic scene (100k nodes, 1M edges, 64 clusters) that measures fps per level in the canvas worker.

Contract Ledger

Surface Authority Behaviour Evidence
setScene without clusters the current contract unchanged existing arms
setScene({…, clusters}) D#19151 H3-e (stable-id clusters) derives centroids, per-cluster ranges, bundles; refuses a clusters array whose length is not the node count new arms, red-first
LOD level D#19151 H3-a far / mid / near by camera distance; draw ranges only new arms + harness fps
getStats().lod this ticket {level, edgesDrawn} new arm
pick at far this ticket -1: no node is drawn there new arm

Acceptance Criteria

  • AC-1: a scene without clusters draws as today (the existing GraphScene arms stay green).
  • AC-2: with clusters, setScene derives the centroids, a contiguous intra-cluster range per cluster, and one bundle per connected cluster pair. A clusters array of the wrong length is refused, naming both lengths. Red on dev first.
  • AC-3: the level follows the camera distance against the fitted distance. A level change uploads nothing.
  • AC-4: near draws the intra-cluster ranges of the nearClusters clusters nearest the camera target, and no others.
  • AC-5: getStats reports lod: {level, edgesDrawn}, and pick answers -1 at far.
  • AC-6: the example's scale harness (100k / 1M / 64 clusters) records fps for the full draw and for far, mid and near in the canvas worker, on a visible, unthrottled page: headless branded Chrome on hardware GL, in both GPU-intent profiles. The PR carries the numbers.

Post-Merge Validation

  • On the operator's display, in a visible window: "100k clustered", "LOD lap", then "Read stats" after about 20 s. Every level holds the display's frame rate. AC-6 ran headless because the agent's Browser pane was hidden (visibilityState: hidden, rAF at 1/s).

Out of Scope

  • Computing clusters or communities; that is the producer's or the layout's job.
  • A layout for 100k nodes (OQ5 is deferred; client-side over bounded neighbourhoods).
  • Selecting or expanding a cluster (the H3-e Institution leaf).
  • Curved or edge-bundled routing: bundles are straight centroid lines.

Avoided Traps

  • An external graph library (Cosmograph, three.js). D#19151 H3-b is rejected, and the operator's position is "our own".
  • Rebuilding buffers on a level change. The probe holds 116–120 fps precisely because a level only changes ranges.
  • Clustering in the renderer. It would compete with H3-e's stable ids, and a selection could not survive a refresh.

Related

#10034 (epic, promotion record row H3-a), D#19151, #19261 / #19274 (GraphScene), #19290 (the reachable seam), Institution #252 / #256, Institution #234, Brain #533.

Decision Record impact: aligned-with D#19151 (H3-a, H3-e; Decision Record: Optional).

Discussion Criteria Mapping:

  • H3-a foveated LOD → AC-2 to AC-6.
  • H3-e "aggregation must not hide cross-cluster relations" → the far and mid levels keep every connected cluster pair as a bundle (AC-2).
  • H3-e "selection must survive refresh" → cluster identity arrives with the scene (AC-2); selection itself is out of scope.

Epic review: cited, not re-run. Grace's review (same model identity), https://github.com/neomjs/neo/issues/10034#issuecomment-5844343545.

Sweeps:

  • The latest 20 open issues, read live at 19:28Z and again at 19:31Z: no equivalent.
  • No LOD or GraphScene claim in the recent A2A window.
  • Memory Core: D#19151's H3-a / H3-e rows and my 19:19Z scale measurement.
  • My own open assignments: none on this surface.

Origin Session ID: e6630386-876d-490e-a3c8-d22c02c3ad21 Retrieval Hint: "GraphScene foveated LOD cluster centroids bundles per-cluster index ranges 100k nodes"

tobiu referenced in commit 6c0681d - "feat(canvas): GraphScene draws clustered scenes through a foveated LOD (#19295) (#19302) on Sep 27, 2026, 10:45 AM
tobiu closed this issue on Sep 27, 2026, 10:45 AM