Skip to content
112 changes: 112 additions & 0 deletions history/2026/2026-09-30-point-render-order.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<!-- suggested path: history/2026/2026-09-30-point-render-order.md -->

# Point render order

**Commits:** `feat(points): set the point render order without reordering the data` (`5d16e38`), `fix(points): apply a pending render order even when a config change runs create() first` (`adb19b9`), `chore(points): test how a render order resolves and draws — and rewrap its JSDoc note` (`b69e398`); rebase follow-ups: `fix(points): the host picking pass draws by the render order — a host picks the point on top` (`7e558f5`), `fix(points): the host picking pass layers highlighted over greyed — a host picks the point it sees` (`704fde4`)

## Why

Points were always drawn in index order, so a higher index was drawn on top, and there was
no other way to control what ends up on top. The only workaround was to reorder the input:
sort every per-point array, remap link endpoints, and keep a permutation map for callbacks
and index-based config. That also changes the simulation, because its random values
(per-point jitter, the per-tick near-field sample hash, per-link distance variation) are
dealt by index. And reordering at runtime meant calling `setPointPositions` again, which
zeroes velocities and re-uploads everything. `setPointRenderOrder` changes only the
drawing order: indices, the layout, and every other per-point array keep their meaning.

## API

`graph.setPointRenderOrder(order: ArrayLike<number> | null)` takes point indices from back
to front, applied on the next `render()`:

| Input | Result |
|---|---|
| a full permutation | drawn exactly in that order |
| some indices, e.g. `[7]` | the listed points go on top in list order, all others underneath in index order |
| an index listed twice | its last position wins, so appending an index brings it to the front |
| negative, fractional, `NaN`, ≥ point count | ignored (an out-of-range index comes back if the count grows) |
| `null`, `[]`, or anything that resolves to index order | index order, unindexed draw path |

Picking (hover, click), label sampling (`getSampledPoints`) and a host's pick pass
(`drawToRenderPass` with `picking`) follow the order. While
`highlightedPointIndices` is set, highlighted points stay above greyed-out ones and the
order applies within each group. The order survives `setPointPositions` and is resolved
again against the new point count.

## What changed

- **Data:** `GraphData.updatePointRenderOrder()` resolves the input into
`pointRenderOrder`, a full `Uint32Array` permutation, or `undefined` for index order.
Like the link grouping, it rebuilds only when the input is reassigned or the point count
changes (`render()` calls `update()` every time).
- **Buffers** (`Points.updateRenderOrder()`, a no-op unless the count or the resolved
order changed):
- `pointRenderOrderBuffer`, a uint32 element index buffer holding the order. It is bound
to the point draw (standard, fringe and both highlight passes), the host picking pass,
the picking fill and
the label-sampling fill, so each draws with `drawElements` in that order. It exists
only while an order is set. Without one, `setIndexBuffer(null)` puts those models back
on the old `drawArrays` path.
- `reversedPointIndexBuffer`, the occlusion-culling core pass buffer, now holds the
order reversed and is rewritten in place when the order changes. Before, it was
rebuilt only on count changes, since its content was always `[N−1 … 0]`.
- `renderRankBuffer`, a new per-point float vertex attribute holding each point's
position in the order.
- **Depth:** `draw-points.vert` writes `z = 1 − 2·(rank + 0.5)/N` instead of using the
point index. The occlusion core and fringe passes agree on what is on top only through
depth. In an indexed draw `gl_VertexID` is the element value (the point index), not the
draw position, so the rank has to come in as its own attribute. With no order the rank
is the identity, so depth is unchanged.

## Verification

`test/point-render-order.test.ts` in `pnpm test`: how an order resolves (partial lists,
repeats, invalid entries, orders that collapse to index order, the count growing and
shrinking, an array edited in place), and that the GPU draws by it — the sampled point,
with every point stacked in one cell, is the one drawn last; an order set before the
device is ready, across count changes, and right before a config change that rebuilds the
graph; and a host pick pass picking the point on top.

Earlier, in-browser tests on Storybook's Vite server:
- **Top-point checks:** three stacked points, checking the pixel color, the picked index,
the sampled index and `gl.getError()`. They cover every input shape above, culling on
and off, highlighting, translucent blending, point count growing and shrinking, absent
(NaN) points listed on top, calls queued before the device is ready, and an array edited
in place and set again.
- **A/B against `main`:** a UMD build of the previous commit and of this branch, loaded
into the same page, over a 20k-point scene with a full-frame pixel compare:
- With no order set, every mode (culling on/off, translucent, highlighting) is
pixel-identical.
- With a random permutation, the output is pixel-identical to the old code drawing
the input arrays physically permuted the same way.

## Notes

- **Cost of a scattered order:** an order that scatters neighboring indices makes the
vertex stage's attribute reads and position-texture fetches incoherent. A coherent
order is free. GPU timer queries at 1M points of 6 px on a vertex-bound 300×150
canvas (Chrome, Apple M3 Max, medians, orders interleaved):

| order | culling on | culling off |
|---|---|---|
| none | 3.42 ms | 4.48 ms |
| reversed | 3.43 ms | 4.50 ms |
| random permutation | 6.54 ms | 6.08 ms |

`setPointRenderOrder` + `render()` for a new 1M-point permutation took 9–19 ms on the
CPU (8 ms for a 1,000-index partial order); a `render()` with the order unchanged took
about 3 ms.
- **Label sampling** still ignores highlight priority, as before: it follows the render
order only.
- **A host's pick pass** layers like the visible draw: greyed points first, highlighted on
top, the order within each group. Before, it drew every point in one pass, so a greyed
point later in index order won the pick over a highlighted one it was drawn under.

## Example

- **Render Order** (`src/stories/points/render-order/index.ts`, Storybook *Examples /
Points*): four overlapping groups interleaved by index. Presets pass a partial order (one
group on top) or a full permutation (small points on top, reversed index order), clicking
a point brings it to the front, and the options check the order with occlusion culling,
translucency and highlighting.
31 changes: 31 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,34 @@ export class Graph implements PositionTextureSource {
this.isPointImageSizesUpdateNeeded = true
}

/**
* Sets the order in which points are drawn, from back to front: a point listed later is
* drawn on top of the points listed before it. By default points draw in index order,
* so a higher index is drawn on top.
*
* @param {ArrayLike<number> | null} order - Point indices from back to front, as a `number[]`
* or any typed array (e.g. a `Uint32Array`). The list may name only some points: they are
* drawn on top in the listed order, and every point left out is drawn underneath them in
* index order. An index listed more than once takes its last position; values that aren't
* point indices are ignored. Pass `null` or `[]` to restore index order.
* Example: `graph.setPointRenderOrder([7])` brings point 7 to the front and leaves the rest in
* index order; `graph.setPointRenderOrder([7, 2])` draws point 2 on top and point 7 just below it.
* @note Only the drawing order changes: point indices, the simulation and every other
* per-point array keep their meaning. Hover and click picking, and a host's pick pass
* (`drawToRenderPass` with `picking`), follow the render order, so the
* point on top is the one they report. While `highlightedPointIndices` is set, highlighted points
* still draw (and pick) above greyed-out ones; the order applies within each group. Label sampling
* (`getSampledPoints`) follows the render order only and gives highlighted points no priority.
* The order is kept across `setPointPositions` calls and re-resolved against the current
* point count: an index beyond it is ignored while it is out of range. The change takes
* effect on the next `render()` call and does not animate.
*/
public setPointRenderOrder (order: ArrayLike<number> | null): void {
if (this._isDestroyed) return
if (this.ensureDevice(() => this.setPointRenderOrder(order))) return
this.graph.inputPointRenderOrder = order ?? undefined
}

/**
* Gets the current sizes of the graph points, with `NaN` sizes
* resolved to the config default (or the exit default for an absent point).
Expand Down Expand Up @@ -1711,6 +1739,9 @@ export class Graph implements PositionTextureSource {
if (this.isPointShapeUpdateNeeded) this.points.updateShape()
if (this.isPointImageIndicesUpdateNeeded) this.points.updateImageIndices()
if (this.isPointImageSizesUpdateNeeded) this.points.updateImageSizes()
// Unconditional (a no-op unless the resolved order changed): a config change can run
// create() before render() resolves a pending order, so a flag would be spent too early.
this.points.updateRenderOrder()

if (this.isLinksUpdateNeeded) this.lines.updatePointsBuffer()
if (this.isLinkColorUpdateNeeded) this.lines.updateColor()
Expand Down
70 changes: 70 additions & 0 deletions src/modules/GraphData/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ export class GraphData {
public pointShapes: Float32Array | undefined
public pointImageIndices: Float32Array | undefined
public pointImageSizes: Float32Array | undefined
/**
* Resolved `setPointRenderOrder` draw order: every point index exactly once, back to
* front (the last entry is drawn on top). `undefined` when points draw in index order.
*/
public pointRenderOrder: Uint32Array | undefined

public links: Float32Array | undefined
public linkColors: Float32Array | undefined
Expand Down Expand Up @@ -117,6 +122,9 @@ export class GraphData {
private _inputLinks: Float32Array | undefined
private _isLinkGroupingStale = true
private _linkGroupingPointsNumber: number | undefined
private _inputPointRenderOrder: ArrayLike<number> | undefined
private _isPointRenderOrderStale = true
private _pointRenderOrderPointsNumber: number | undefined
private _sourceIndexToTargetIndices: PointPairs | undefined
private _targetIndexToSourceIndices: PointPairs | undefined

Expand Down Expand Up @@ -166,11 +174,24 @@ export class GraphData {
return this._inputLinks
}

/**
* Raw draw order from `setPointRenderOrder`. Assigning, even the same array again,
* re-resolves `pointRenderOrder` on the next `update()`.
*/
public get inputPointRenderOrder (): ArrayLike<number> | undefined {
return this._inputPointRenderOrder
}

public set inputLinks (links: Float32Array | undefined) {
this._inputLinks = links
this._isLinkGroupingStale = true
}

public set inputPointRenderOrder (order: ArrayLike<number> | undefined) {
this._inputPointRenderOrder = order
this._isPointRenderOrderStale = true
}

public updatePoints (): void {
// Positions must hold whole [x, y] pairs: an odd length makes `pointsNumber`
// fractional, which the degree build passes to `new Array()`.
Expand Down Expand Up @@ -347,6 +368,54 @@ export class GraphData {
}
}

/**
* Resolves `inputPointRenderOrder` into `pointRenderOrder`, a full back-to-front
* permutation of the point indices. The input may list only some points: those are
* drawn on top in the listed order, and every unlisted point goes underneath them in
* index order. An index listed more than once takes its last position; entries that
* aren't point indices are ignored. An order that resolves to plain index order leaves
* `pointRenderOrder` undefined, so rendering takes the unindexed path.
* The O(n) rebuild runs only when the input is reassigned or the point count changes.
*/
public updatePointRenderOrder (): void {
if (!this._isPointRenderOrderStale && this._pointRenderOrderPointsNumber === this.pointsNumber) return
this._isPointRenderOrderStale = false
this._pointRenderOrderPointsNumber = this.pointsNumber

const pointsNumber = this.pointsNumber ?? 0
const input = this._inputPointRenderOrder
if (!input || input.length === 0 || pointsNumber === 0) {
this.pointRenderOrder = undefined
return
}

const order = new Uint32Array(pointsNumber)
const isListed = new Uint8Array(pointsNumber)
// Walk the input from its end so a repeated index keeps its last (topmost)
// position; listed points fill the order from the top down.
let top = pointsNumber
for (let k = input.length - 1; k >= 0; k--) {
const index = input[k]
if (!this.isPointIndex(index) || isListed[index]) continue
isListed[index] = 1
order[--top] = index
}
// Unlisted points fill the rest from the bottom, in index order.
let bottom = 0
for (let i = 0; i < pointsNumber && bottom < top; i++) {
if (!isListed[i]) order[bottom++] = i
}

let isIndexOrder = true
for (let k = 0; k < pointsNumber; k++) {
if (order[k] !== k) {
isIndexOrder = false
break
}
}
this.pointRenderOrder = isIndexOrder ? undefined : order
}

/**
* Whether a point draws an atlas image: an image index in range of the images
* set. Only then does its image size count toward its footprint — the size slot
Expand Down Expand Up @@ -538,6 +607,7 @@ export class GraphData {
this.updatePointShape()
this.updatePointImageIndices()
this.updatePointImageSizes()
this.updatePointRenderOrder()

this.updateLinks()
this.updateLinkColor()
Expand Down
12 changes: 7 additions & 5 deletions src/modules/Points/draw-points.vert
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ in vec4 targetColor;
in float shape;
in float imageIndex;
in float imageSize;
// Position of this point in the render order: 0 is drawn first (bottom)
in float renderRank;

uniform sampler2D positionsTexture;
uniform sampler2D pointStatus;
Expand Down Expand Up @@ -173,11 +175,11 @@ void main() {
#else
vec3 finalPosition = transformationMatrix * vec3(normalizedPosition, 1);
#endif
// Depth encodes stacking order: higher point index = drawn on top = nearer
// (smaller z). Harmless when depth testing is off (depthCompare 'always').
float linearIndex = pointIndices.y * pointsTextureSize + pointIndices.x;
vPointIndex = linearIndex;
float depthZ = 1.0 - 2.0 * (linearIndex + 0.5) / max(pointsNumber, 1.0);
vPointIndex = pointIndices.y * pointsTextureSize + pointIndices.x;
// Depth encodes stacking order: later in the render order = drawn on top = nearer
// (smaller z). The rank equals the point index unless setPointRenderOrder is used.
// Harmless when depth testing is off (depthCompare 'always').
float depthZ = 1.0 - 2.0 * (renderRank + 0.5) / max(pointsNumber, 1.0);
gl_Position = vec4(finalPosition.rg, depthZ, 1.0);

// Resolve NaN channels against the animated exit ramp before mixing — default
Expand Down
Loading
Loading