diff --git a/AGENTS.md b/AGENTS.md index d76ca998..760548b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,13 @@ contribution process, see `CONTRIBUTING.md`, `CHARTER.md`, `CODE_OF_CONDUCT.md`, validation and default-fill. - `modules/` — per-force and per-render modules (ForceManyBody, ForceLink, ForceGravity, ForceCenter, ForceMouse, Clusters, Points, Lines, Zoom, Drag, Store), each with its GLSL shaders. + In those shaders **every data-texture read uses `texelFetch`** — `texelFetch(tex, ivec2(index), 0)` + where an index is at hand, `texelFetch(tex, ivec2(gl_FragCoord.xy), 0)` in a full-screen pass that + writes one output per element. Never address a data texture with a normalized coordinate: + `index / textureSize` lands on a texel *boundary*, where the sampler's floor can fall to the previous + texel and silently return another element's data, and the `(index + 0.5) / textureSize` centre form + only hides that behind a half-texel margin. `texture()` is reserved for genuine UV sampling, where a + continuous coordinate and filtering are the point — in this codebase, only the image atlas. - `stories/` — Storybook examples plus the `configuration.mdx` / `api-reference.mdx` docs — the best worked examples of building the input arrays. One sidebar section per `*.stories.ts` file, each named for the feature area it teaches (get-started, points, links, forces, interaction, diff --git a/history/2026/2026-07-30-data-texture-addressing.md b/history/2026/2026-07-30-data-texture-addressing.md new file mode 100644 index 00000000..d71a93d5 --- /dev/null +++ b/history/2026/2026-07-30-data-texture-addressing.md @@ -0,0 +1,162 @@ + + +# Addressing data textures by texel index + +**Commits:** `fix(shaders): read data textures by texel index, never by coordinate` +(`c440ce4`), `fix(points): tracked points follow the point, not a baked texel` +(`3f3a3b8`), `fix(points): close the lasso with integer modulo` (`d7a5225`), +`fix(force): declare highp int for near-field point indices` (`71206e2`) + +## Why + +Ten fetches in the force shaders addressed their data textures at the texel +**corner**, `index / textureSize`. NEAREST selection is a *floor* of the +size-scaled coordinate, and a corner coordinate sits exactly on the boundary +between texel `index-1` and `index` — zero margin. A driver whose arithmetic +falls even one ULP short returns the previous texel, silently handing the shader +another point's data. + +It happens constantly. Measured on an Apple M3 through ANGLE Metal, **2284 of +the 4095 texture sizes from 2 to 4096 misfetch at least one index**; at size 100, +3916 of 10000 texels read their neighbour. Only powers of two are immune, and +`pointsTextureSize` / `clustersTextureSize` are `ceil(sqrt(count))` — so roughly +half of all point counts land on an affected size. Which sizes fail is a *driver* +property rather than an arithmetic one: SwiftShader's failing set is nearly +disjoint from Metal's, so "pick a safe size" is not a workaround. + +The engine's own writes never had the problem — every pass rasterises to texel +centres, `2.0 * (index + 0.5) / size - 1.0`. It was only the reads that failed to +invert them. + +Users saw it in the cluster force, where the misfetch is not one term in a sum but +the entire target of the force, shared by every member of a cluster — so a whole +cluster relocates onto its neighbour. With 1089 pinned clusters +(`clustersTextureSize` 33) only **7.1%** of points reached their own cluster. + +## The rule + +A data texture is an array, so it is addressed by an index: + +- `texelFetch(tex, ivec2(index), 0)` where an index is at hand; +- `texelFetch(tex, ivec2(gl_FragCoord.xy), 0)` in a full-screen pass that writes + one output per element. + +`texture()` survives only where the coordinate is genuinely continuous and +filtering is the point — in this codebase, the image atlas in `draw-points.frag` +and nothing else. `AGENTS.md` carries the rule. + +## Why not just add the half texel + +The obvious smaller fix is `(index + 0.5) / size`, which is correct: half a texel +of margin absorbs any plausible driver error. It was rejected, and the reads +*already* written that way were converted too, because the margin only hides the +fragility — it is an argument that has to be re-made at every new call site, and +the corner form is what you get when someone doesn't make it. An integer texel +does no coordinate arithmetic at all, and ignores filter and wrap state, so there +is nothing left to get wrong. This also matches the WebGL 2 GPGPU idiom, where +`texelFetch` is what replaced the WebGL 1 half-texel workaround. That workaround +was never a style choice: GLSL ES 1.00 reserved `%` and had no `texelFetch`, so +index-to-texel was float `mod()`/`floor()` by construction — the arithmetic whose +boundary failure the sweep notes below measure (`mod(33.0, 33.0) = 33`) — and +half-texel offsets plus epsilons were how engines held it together. WebGL 2 +removed the constraint; the rule here finishes the removal. + +For the same reason the full-screen passes stopped reading an interpolated quad +varying: a rasterised coordinate re-introduces exactly the dependence being +removed. `quad.vert`'s `textureCoords` output had no consumer left and is gone. + +## Why not `textureSize()` + +Considered and rejected for the size uniforms that remain. Most of them describe +the **render target** — `gl_Position` scatter destinations — which GLSL cannot +query; `textureSize()` only answers for a texture the shader samples. Others +encode a feature flag in the size (`linkStatusTextureSize > 0.0` means +highlighting is off, `imageAtlasCoordsTextureSize == 0` means no images) while a +1×1 placeholder is bound, so the builtin would report `1` and destroy the +distinction. Adopting it would have left two mechanisms for one quantity. + +The one place it *is* right is `find-points-in-polygon.frag`, where the +alternative was never a uniform but a re-derivation of `ceil(sqrt(pathLength))` +that duplicated the allocation's own formula (and shadowed the builtin with a +local of the same name). That one now asks the texture. + +## Two dependencies changed underneath + +- **The 1×1 exit-texture stand-in.** While no point is absent, `exitTexture` is a + 1×1 all-zero texture rather than a `pointsTextureSize²` one. The + [NaN point removal](2026-06-27-nan-point-removal.md) entry justifies it with + "any sample returns present" — true of `texture()` with `CLAMP_TO_EDGE`. The + shaders now `texelFetch` it at each point's own texel, which is *out of range* + for every point but the first, so the optimisation rests instead on WebGL 2 + defining an out-of-range `texelFetch` as zero (its conformance suite tests + exactly that; GLSL ES alone leaves it undefined). Same answer, different + mechanism, and it is documented at the allocation. +- **Stale tracked indices — since resolved.** `trackPointPositionsByIndices` baked + its texel pairs from the `pointsTextureSize` current at call time and never + re-baked them when the point count changed. A stale index used to clamp and + report *some* real point's position; after this sweep it read out of range and + reported `(0, 0)`, so a tracked set landed at the origin and read like a layout + bug. `3f3a3b8` closes it with this entry's own rule rather than the re-bake + that was owed: the table stores the raw point index, and `track-positions.frag` + derives the texel at read time from `textureSize(positionsTexture, 0)` — + legitimate there, since the shader samples that texture. With no baked mapping + left to go stale, the tracked set is declarative: an index at or past the + current count is omitted like an absent point and comes back if the count + grows to include it. + +## Verification + +Behaviour was pinned from both directions: the bug fix had to *change* something, +the rest had to change nothing. + +| check | result | +|---|---| +| pinned clusters at `clustersTextureSize` 33 | 7.1% → **100%** on the correct cluster | +| control at exact size 23 | 100% before and after | +| point counts on broken sizes (33, 55, 100) | 100% correct | +| absent points at 10% and 50% | no `NaN` reached any present point | +| render path (5000 points, 8000 links) | byte-identical frame, same SHA-256 | +| rect / polygon / sampling read-backs | identical results | +| polygon vs CPU ray casting, 5 path widths | exact match, both directions | + +**Scope of that evidence:** one GPU — an Apple M3 via ANGLE Metal, plus +SwiftShader for the isolated fetch probe. No Windows/D3D11, Adreno or Mali +hardware was exercised. + +## Notes for the next sweep + +- **Not every shader is a `.vert`/`.frag` file.** `ForceLink/force-spring.ts` + returns its shader from a function, because the link loop's bound is baked in. + A codebase-wide sweep filtered by extension misses it — grep for + `#version 300 es` instead. It was missed on the first pass here. +- **A uniform-block member spans seven places** that must move together: the + `layout(std140)` block, its `#define` alias and the `#else` non-UBO declaration + in the shader; the `UniformStore<{…}>` field type, `uniformTypes`, + `defaultUniforms` and every `setUniforms` call in TypeScript. Nothing verifies + they agree — drop a member from one side and every member after it reads from + the wrong offset, silently. Nine members were removed here (two blocks emptied + entirely, taking their `UniformStore` with them), each in lockstep, with every + block's order re-checked against its `uniformTypes` afterwards. +- **Fragment shaders default `int` to `mediump`** — spec minimum 16 bits. Every + shader here declares `precision highp float;` and none declared the int + counterpart, so fragment-stage integer math on a raw point/link index above + 32 767 is only safe where the driver widens mediump (desktop/ANGLE does; the + spec does not promise it). `track-positions.frag` and `force-nearfield.frag` — + the shaders that hold raw indices — now declare `precision highp int;` + (`71206e2`); SwiftShader, Chrome's fallback renderer, reports mediump int as + exactly 16 bits, so the gap is real on reachable stacks. Bounded ints (texel + coordinates, grid cells) keep the default, and sampler precision — `lowp` by + default, the same spec posture — was deliberately left alone: no reachable + implementation narrows it. +- **Deriving a texel from an index needs integer math.** A GPU probe measured + float `mod(33.0, 33.0)` returning `33` — the boundary floor this entry is + about, surfacing at index 33 — while integer `%` and `/` were exact at every + width for indices up to `2^24 − 1`. Above `2^24` an index no longer survives + float32 storage at all; that ceiling is shared by every float-carried index + in the engine (`linkIndices`, and now the tracked-index table). The codebase + held one live instance: `find-points-in-polygon.frag` wrapped its last edge + with float `mod`, and at 33 path vertices closed the lasso onto a zero-filled + padding texel — four outsiders selected, the enclosed point dropped + (`d7a5225`). +- `npm run build` exits 0 even when TypeScript errors are printed, so a green + build is not a type check — `npx tsc --noEmit` is. diff --git a/src/config.ts b/src/config.ts index 2d9380b4..a52ed188 100644 --- a/src/config.ts +++ b/src/config.ts @@ -307,9 +307,9 @@ export interface GraphConfigInterface { /** * Controls alpha blending for link rendering. * - * When `true` (default), links are drawn with standard source-over alpha blending, - * which is required for transparency, opacity, and antialiased link edges. - * Disable if you want much faster links rendering. + * When `true` (default), links are drawn with standard source-over alpha blending + * for transparency and soft antialiased edges. + * When `false`, links are drawn as opaque hard edges (faster for dense graphs). * Default value: `true` */ linkBlending: boolean; diff --git a/src/helper.ts b/src/helper.ts index 61e8d134..06a55fd8 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -88,14 +88,34 @@ export function readPixels (device: Device, fbo: Framebuffer, sourceX = 0, sourc }) as Float32Array } +/** + * Generates a short random id. Used to namespace the listeners each `Graph` + * instance registers on shared globals like `document`, so instances never + * replace or remove each other's handlers. + */ +export function generateRandomId (): string { + const words = new Uint32Array(2) + crypto.getRandomValues(words) + // Each word is padded to the 7 base-36 digits a full uint32 needs, so the + // boundary between them is fixed: with variable widths two different pairs + // can concatenate to the same id. + return Array.from(words, (word) => word.toString(36).padStart(7, '0')).join('') +} + /** * Extracts point indices from a pixel readback buffer. * Every 4th value (R channel) is checked — non-zero means the point at that index was found. + * + * @param pointsNumber Number of real points. The texture is square, so the texels + * past this count are padding holding position `(0, 0)`; they match any search area + * covering the space origin and would be reported as points that do not exist. + * Omit it to read the whole buffer. */ -export function extractIndicesFromPixels (pixels: Float32Array): number[] { +export function extractIndicesFromPixels (pixels: Float32Array, pointsNumber?: number): number[] { const result: number[] = [] - for (let i = 0; i < pixels.length; i += 4) { - if (pixels[i] !== 0) result.push(i / 4) + const count = Math.min(pixels.length / 4, pointsNumber ?? Infinity) + for (let i = 0; i < count; i += 1) { + if (pixels[i * 4] !== 0) result.push(i) } return result } diff --git a/src/index.ts b/src/index.ts index d3086e70..cad0b4c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import { Device, Framebuffer, luma } from '@luma.gl/core' import { webgl2Adapter } from '@luma.gl/webgl' import { applyConfig, createDefaultConfig, resetConfigToDefaults, GraphConfigInterface, type GraphConfig } from '@/graph/config' -import { getRgbaColor, getMaxPointSize, readPixels, extractIndicesFromPixels, sanitizeHtml, isPointAbsent } from '@/graph/helper' +import { getRgbaColor, getMaxPointSize, readPixels, extractIndicesFromPixels, sanitizeHtml, isPointAbsent, generateRandomId } from '@/graph/helper' import { ForceCenter } from '@/graph/modules/ForceCenter' import { ForceCollision } from '@/graph/modules/ForceCollision' import { ForceGravity } from '@/graph/modules/ForceGravity' @@ -77,6 +77,12 @@ export class Graph { */ private _shouldSuppressNextClick = false + /** + * Namespaces the listeners this instance registers on `document`. With a + * shared namespace, instances replace each other's handlers and one + * instance's destroy() removes another's. + */ + private readonly _instanceId = generateRandomId() private store = new Store() private points: Points | undefined private lines: Lines | undefined @@ -361,8 +367,8 @@ export class Graph { .on('contextmenu.cosmos', this.onContextMenu.bind(this)) select(document) - .on('keydown.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = true }) - .on('keyup.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = false }) + .on(`keydown.cosmos-${this._instanceId}`, (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = true }) + .on(`keyup.cosmos-${this._instanceId}`, (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = false }) this.zoomInstance.behavior .on('start.detect', (e: D3ZoomEvent) => { @@ -441,7 +447,7 @@ export class Graph { this.store.updateLinkHoveringEnabled(this.config) - if (this.config.showFPSMonitor) this.fpsMonitor = new FPSMonitor(this.canvas) + if (this.config.showFPSMonitor) this.fpsMonitor = new FPSMonitor(this.canvas, this.store.div) if (this.config.randomSeed !== undefined) this.store.addRandomSeed(this.config.randomSeed) @@ -912,8 +918,11 @@ export class Graph { } /** - * Renders the graph and starts rendering. - * Does NOT modify simulation state - use start(), stop(), pause(), unpause() to control simulation. + * Applies pending data changes and renders the graph. + * Does not start or stop the simulation — use start(), stop(), pause(), unpause() for that. + * Two exceptions: the `simulationAlpha` argument sets the alpha when provided, and a position + * transition started with a positive `transitionDuration` (or its `config.transitionDuration` + * fallback) pauses a running simulation (see `setPointPositions`). * * @param {number} [simulationAlpha] - Optional alpha value to set. * - If 0: Sets alpha to 0, simulation stops after one frame (graph becomes static). @@ -1220,7 +1229,7 @@ export class Graph { const h = this.store.screenSize[1] this.store.searchArea = [[rect[0][0], (h - rect[1][1])], [rect[1][0], (h - rect[0][1])]] if (!this.points.findPointsInRect()) return [] - return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer)) + return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer), this.graph.pointsNumber) } /** @@ -1247,7 +1256,7 @@ export class Graph { const convertedPath = polygonPath.map(([x, y]) => [x, h - y] as [number, number]) this.points.updatePolygonPath(convertedPath) if (!this.points.findPointsInPolygon()) return [] - return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer)) + return extractIndicesFromPixels(readPixels(this.device, this.points.searchFbo as Framebuffer), this.graph.pointsNumber) } /** @@ -1318,7 +1327,7 @@ export class Graph { public getPointRadiusByIndex (index: number): number | undefined { if (this._isDestroyed) return undefined if (this.graph.pointSizes === undefined && this.graph.pointImageSizes === undefined) return undefined - if (index < 0 || index >= (this.graph.pointsNumber ?? 0)) return undefined + if (!this.graph.isPointIndex(index)) return undefined const shapeSize = this.graph.getResolvedPointSize(index) const imageSize = this.graph.pointImageSizes?.[index] return Math.max(shapeSize, imageSize ?? 0) @@ -1326,6 +1335,12 @@ export class Graph { /** * Track multiple point positions by their indices on each Cosmos tick. + * + * The tracked set is declarative: an index follows its point whenever that + * point exists. Growing or shrinking the point count later keeps tracking + * correct — an index past the current count simply reports nothing until the + * count grows to include it. Tracking may also be set up before the first + * `setPointPositions` call. * @param indices Array of points indices. */ public trackPointPositionsByIndices (indices: number[]): void { @@ -1344,7 +1359,8 @@ export class Graph { * @note An **absent** tracked point (removed via a `NaN` position — see `setPointPositions`) is * omitted from the map — a missing key means "this point is gone". React to absence yourself * (e.g. hide its label); the entry disappears as soon as the point is removed, even while its - * fade-out is still playing. + * fade-out is still playing. A tracked index with no point behind it (at or past the current + * point count) is omitted the same way, and reappears if the count grows to include it. */ public getTrackedPointPositionsMap (): ReadonlyMap { if (this._isDestroyed || !this.points) return new Map() @@ -1429,8 +1445,10 @@ export class Graph { } /** - * Start the simulation. - * This only controls the simulation state, not rendering. + * Start the simulation. Data ingest and the initial render belong to `render()`; + * this method sets the simulation running and requests frames to drive it. + * An active position transition is ended immediately (`onTransitionEnd` + * fires with `interrupted: true`). * If the simulation is already running, calling `start(alpha)` reheats it by * resetting `alpha` and `simulationProgress` without firing * `onSimulationStart` again. @@ -1486,6 +1504,8 @@ export class Graph { /** * Unpause the simulation. This method resumes a paused * simulation and continues its execution. + * An active position transition is ended immediately (`onTransitionEnd` + * fires with `interrupted: true`). */ public unpause (): void { if (this._isDestroyed) return @@ -1543,7 +1563,7 @@ export class Graph { .on('.zoom', null) } - select(document).on('.cosmos', null) + select(document).on(`.cosmos-${this._instanceId}`, null) if (this.zoomInstance?.behavior) { this.zoomInstance.behavior @@ -1600,16 +1620,17 @@ export class Graph { this.attributionDivElement.parentNode.removeChild(this.attributionDivElement) } - document.getElementById('gl-bench-style')?.remove() - this.canvasD3Selection = undefined this.attributionDivElement = undefined } /** - * Applies pending data changes (positions, colors, sizes, shapes, links, forces, clusters) - * to the graph visualization. Call this after setting data via methods like `setPointPositions`, - * `setPointColors`, `setLinks`, etc. if you need to apply changes without calling `render()`. + * Uploads the processed data to the GPU for every channel whose update flag is set + * (positions, colors, sizes, shapes, images, links, forces, clusters) and requests a redraw. + * An internal stage of the render pipeline: it does not ingest new input arrays, so data + * passed to `setPointPositions`, `setPointColors`, `setLinks`, etc. takes effect only on + * the next `render()`. To apply new data while keeping the current alpha, call + * `render(undefined)` — or `render(undefined, 0)` to also snap instead of animating. */ public create (): void { if (this._isDestroyed) return @@ -1662,7 +1683,7 @@ export class Graph { this.isForceLinkUpdateNeeded = false this.isForceCenterUpdateNeeded = false - // Public contract: applies data changes without render() — draw them + // create() presents what it uploads — callers don't need a separate frame kick this.requestRender() } @@ -1681,8 +1702,11 @@ export class Graph { * @returns An array of tuple positions */ public pair (pointPositions: number[]): [number, number][] { - const arr = new Array(pointPositions.length / 2) as [number, number][] - for (let i = 0; i < pointPositions.length / 2; i++) { + // A trailing x with no y has no pair — `new Array()` throws on a fractional + // length, so the odd value is dropped rather than taking the caller down. + const pairsNumber = Math.floor(pointPositions.length / 2) + const arr = new Array(pairsNumber) as [number, number][] + for (let i = 0; i < pairsNumber; i++) { arr[i] = [pointPositions[i * 2] as number, pointPositions[i * 2 + 1] as number] } @@ -1726,6 +1750,9 @@ export class Graph { if (prevConfig.pointDefaultSize !== this.config.pointDefaultSize) { this.graph.updatePointSize() this.points?.updateSize() + // Image sizes default to a copy of point sizes, so they follow this change. + this.graph.updatePointImageSizes() + this.points?.updateImageSizes() } if (prevConfig.pointDefaultShape !== this.config.pointDefaultShape) { this.graph.updatePointShape() @@ -1824,7 +1851,7 @@ export class Graph { } if (prevConfig.showFPSMonitor !== this.config.showFPSMonitor) { if (this.config.showFPSMonitor) { - this.fpsMonitor = new FPSMonitor(this.canvas) + this.fpsMonitor = new FPSMonitor(this.canvas, this.store.div) } else { this.fpsMonitor?.destroy() this.fpsMonitor = undefined @@ -1833,6 +1860,14 @@ export class Graph { if (prevConfig.enableZoom !== this.config.enableZoom || prevConfig.enableDrag !== this.config.enableDrag) { this.updateZoomDragBehaviors() } + // The sampling grids are otherwise only rebuilt on screen resize, which + // would silently ignore a runtime change of the sampling distances. + if (prevConfig.pointSamplingDistance !== this.config.pointSamplingDistance) { + this.points?.updateSampledPointsGrid() + } + if (prevConfig.linkSamplingDistance !== this.config.linkSamplingDistance) { + this.lines?.updateSampledLinksGrid() + } if (prevConfig.onLinkClick !== this.config.onLinkClick || prevConfig.onLinkContextMenu !== this.config.onLinkContextMenu || diff --git a/src/modules/Clusters/calculate-centermass.vert b/src/modules/Clusters/calculate-centermass.vert index bbd3b01d..193052a5 100644 --- a/src/modules/Clusters/calculate-centermass.vert +++ b/src/modules/Clusters/calculate-centermass.vert @@ -9,14 +9,11 @@ uniform sampler2D exitTexture; #ifdef USE_UNIFORM_BUFFERS layout(std140) uniform calculateCentermassUniforms { - float pointsTextureSize; float clustersTextureSize; } calculateCentermass; -#define pointsTextureSize calculateCentermass.pointsTextureSize #define clustersTextureSize calculateCentermass.clustersTextureSize #else -uniform float pointsTextureSize; uniform float clustersTextureSize; #endif @@ -27,18 +24,20 @@ out vec4 rgba; void main() { rgba = vec4(0.0); + ivec2 pointTexel = ivec2(pointIndices); + // Absent points must not contribute to their cluster's centroid. (exit.G = absent) - vec4 exitStatus = texture(exitTexture, pointIndices / pointsTextureSize); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); if (exitStatus.g > 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; } - vec4 pointPosition = texture(positionsTexture, pointIndices / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); rgba = vec4(pointPosition.xy, 1.0, 0.0); - vec4 pointClusterIndices = texture(clusterTexture, pointIndices / pointsTextureSize); + vec4 pointClusterIndices = texelFetch(clusterTexture, pointTexel, 0); vec2 xy = vec2(0.0); if (pointClusterIndices.x >= 0.0 && pointClusterIndices.y >= 0.0) { xy = 2.0 * (pointClusterIndices.xy + 0.5) / clustersTextureSize - 1.0; diff --git a/src/modules/Clusters/force-cluster.frag b/src/modules/Clusters/force-cluster.frag index 70168de6..3dc5fc48 100644 --- a/src/modules/Clusters/force-cluster.frag +++ b/src/modules/Clusters/force-cluster.frag @@ -12,37 +12,36 @@ uniform sampler2D clusterForceCoefficient; #ifdef USE_UNIFORM_BUFFERS layout(std140) uniform applyForcesUniforms { float alpha; - float clustersTextureSize; float clusterCoefficient; } applyForces; #define alpha applyForces.alpha -#define clustersTextureSize applyForces.clustersTextureSize #define clusterCoefficient applyForces.clusterCoefficient #else uniform float alpha; -uniform float clustersTextureSize; uniform float clusterCoefficient; #endif -in vec2 textureCoords; - out vec4 fragColor; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec4 velocity = vec4(0.0); - vec4 pointClusterIndices = texture(clusterTexture, textureCoords); + vec4 pointClusterIndices = texelFetch(clusterTexture, pointTexel, 0); // no cluster, so no forces if (pointClusterIndices.x >= 0.0 && pointClusterIndices.y >= 0.0) { + // clusterTexture stores whole texel coordinates, so truncating to int is exact. + ivec2 clusterTexel = ivec2(pointClusterIndices.xy); // positioning points to custom cluster position or either to the center of mass - vec2 clusterPositions = texture(clusterPositionsTexture, pointClusterIndices.xy / clustersTextureSize).xy; + vec2 clusterPositions = texelFetch(clusterPositionsTexture, clusterTexel, 0).xy; if (clusterPositions.x < 0.0 || clusterPositions.y < 0.0) { - vec4 centermassValues = texture(centermassTexture, pointClusterIndices.xy / clustersTextureSize); + vec4 centermassValues = texelFetch(centermassTexture, clusterTexel, 0); clusterPositions = centermassValues.xy / centermassValues.b; } - vec4 clusterCustomCoeff = texture(clusterForceCoefficient, textureCoords); + vec4 clusterCustomCoeff = texelFetch(clusterForceCoefficient, pointTexel, 0); vec2 distVector = clusterPositions.xy - pointPosition.xy; float dist = length(distVector); if (dist > 0.0) { diff --git a/src/modules/Clusters/index.ts b/src/modules/Clusters/index.ts index 43f28727..d59756b0 100644 --- a/src/modules/Clusters/index.ts +++ b/src/modules/Clusters/index.ts @@ -40,7 +40,6 @@ export class Clusters extends CoreModule { // Uniform stores for scalar uniforms private calculateCentermassUniformStore: UniformStore<{ calculateCentermassUniforms: { - pointsTextureSize: number; clustersTextureSize: number; }; }> | undefined @@ -48,7 +47,6 @@ export class Clusters extends CoreModule { private applyForcesUniformStore: UniformStore<{ applyForcesUniforms: { alpha: number; - clustersTextureSize: number; clusterCoefficient: number; }; }> | undefined @@ -264,11 +262,9 @@ export class Clusters extends CoreModule { this.calculateCentermassUniformStore ||= new UniformStore(device, { calculateCentermassUniforms: { uniformTypes: { - pointsTextureSize: 'f32', clustersTextureSize: 'f32', }, defaultUniforms: { - pointsTextureSize: store.pointsTextureSize, clustersTextureSize: (this.clustersTextureSize ?? 0), }, }, @@ -312,12 +308,10 @@ export class Clusters extends CoreModule { applyForcesUniforms: { uniformTypes: { alpha: 'f32', - clustersTextureSize: 'f32', clusterCoefficient: 'f32', }, defaultUniforms: { alpha: store.alpha, - clustersTextureSize: (this.clustersTextureSize ?? 0), clusterCoefficient: this.config.simulationCluster, }, }, @@ -370,7 +364,6 @@ export class Clusters extends CoreModule { // Update UniformStore with current values this.calculateCentermassUniformStore.setUniforms({ calculateCentermassUniforms: { - pointsTextureSize: this.store.pointsTextureSize, clustersTextureSize: (this.clustersTextureSize ?? 0), }, }) @@ -453,7 +446,6 @@ export class Clusters extends CoreModule { this.applyForcesUniformStore.setUniforms({ applyForcesUniforms: { alpha: this.store.alpha, - clustersTextureSize: (this.clustersTextureSize ?? 0), clusterCoefficient: this.config.simulationCluster, }, }) diff --git a/src/modules/FPSMonitor/index.ts b/src/modules/FPSMonitor/index.ts index 8764e2af..d20ad6d3 100644 --- a/src/modules/FPSMonitor/index.ts +++ b/src/modules/FPSMonitor/index.ts @@ -1,15 +1,18 @@ -import { select } from 'd3-selection' import GLBench from 'gl-bench' import { benchCSS } from './css' export class FPSMonitor { private bench: GLBench | undefined + private container: HTMLElement - public constructor (canvas: HTMLCanvasElement) { + public constructor (canvas: HTMLCanvasElement, container?: HTMLElement) { + // Scope the widget (and the style element gl-bench injects) to the graph's + // container, so multiple Graph instances don't remove each other's monitor. + this.container = container ?? document.body this.destroy() const gl = (canvas.getContext('webgl') || canvas.getContext('experimental-webgl')) as WebGL2RenderingContext - this.bench = new GLBench(gl, { css: benchCSS }) + this.bench = new GLBench(gl, { css: benchCSS, dom: this.container }) } public begin (): void { @@ -23,6 +26,9 @@ export class FPSMonitor { public destroy (): void { this.bench = undefined - select('#gl-bench').remove() + // gl-bench appends both elements as direct children of the container; + // ':scope >' keeps a monitor in a nested container out of reach. + this.container.querySelector(':scope > #gl-bench')?.remove() + this.container.querySelector(':scope > #gl-bench-style')?.remove() } } diff --git a/src/modules/ForceCenter/calculate-centermass.vert b/src/modules/ForceCenter/calculate-centermass.vert index b7a006f6..58bac773 100644 --- a/src/modules/ForceCenter/calculate-centermass.vert +++ b/src/modules/ForceCenter/calculate-centermass.vert @@ -4,16 +4,6 @@ precision highp float; uniform sampler2D positionsTexture; uniform sampler2D exitTexture; -#ifdef USE_UNIFORM_BUFFERS -layout(std140) uniform calculateCentermassUniforms { - float pointsTextureSize; -} calculateCentermass; - -#define pointsTextureSize calculateCentermass.pointsTextureSize -#else -uniform float pointsTextureSize; -#endif - in vec2 pointIndices; out vec4 rgba; @@ -21,16 +11,18 @@ out vec4 rgba; void main() { rgba = vec4(0.0); + ivec2 pointTexel = ivec2(pointIndices); + // Absent points must not contribute to the centroid — a NaN position would // poison the sum and break the force for every point. (exit.G = current absence) - vec4 exitStatus = texture(exitTexture, pointIndices / pointsTextureSize); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); if (exitStatus.g > 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; } - vec4 pointPosition = texture(positionsTexture, pointIndices / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); rgba = vec4(pointPosition.xy, 1.0, 0.0); gl_Position = vec4(0.0, 0.0, 0.0, 1.0); diff --git a/src/modules/ForceCenter/force-center.frag b/src/modules/ForceCenter/force-center.frag index 29a15793..b042fabc 100644 --- a/src/modules/ForceCenter/force-center.frag +++ b/src/modules/ForceCenter/force-center.frag @@ -17,13 +17,14 @@ uniform float centerForce; uniform float alpha; #endif -in vec2 textureCoords; out vec4 fragColor; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec4 velocity = vec4(0.0); - vec4 centermassValues = texture(centermassTexture, vec2(0.0)); + vec4 centermassValues = texelFetch(centermassTexture, ivec2(0), 0); vec2 centermassPosition = centermassValues.xy / centermassValues.b; vec2 distVector = centermassPosition - pointPosition.xy; float dist = sqrt(dot(distVector, distVector)); diff --git a/src/modules/ForceCenter/index.ts b/src/modules/ForceCenter/index.ts index 17b5f715..d8039b87 100644 --- a/src/modules/ForceCenter/index.ts +++ b/src/modules/ForceCenter/index.ts @@ -19,12 +19,6 @@ export class ForceCenter extends CoreModule { private forceVertexCoordBuffer: Buffer | undefined - private calculateUniformStore: UniformStore<{ - calculateCentermassUniforms: { - pointsTextureSize: number; - }; - }> | undefined - private forceUniformStore: UniformStore<{ forceCenterUniforms: { centerForce: number; @@ -86,14 +80,6 @@ export class ForceCenter extends CoreModule { data: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), }) - this.calculateUniformStore ||= new UniformStore(device, { - calculateCentermassUniforms: { - uniformTypes: { - pointsTextureSize: 'f32', - }, - }, - }) - this.forceUniformStore ||= new UniformStore(device, { forceCenterUniforms: { uniformTypes: { @@ -117,9 +103,6 @@ export class ForceCenter extends CoreModule { USE_UNIFORM_BUFFERS: true, }, bindings: { - // Create uniform buffer binding - // Update it later by calling uniformStore.setUniforms() - calculateCentermassUniforms: this.calculateUniformStore.getManagedUniformBuffer('calculateCentermassUniforms'), // All texture bindings will be set dynamically in run() method }, parameters: { @@ -166,7 +149,7 @@ export class ForceCenter extends CoreModule { public run (): void { const { device, store, points } = this if (!points) return - if (!this.calculateCentermassCommand || !this.calculateUniformStore || !this.runCommand || !this.forceUniformStore) return + if (!this.calculateCentermassCommand || !this.runCommand || !this.forceUniformStore) return if (!this.centermassFbo || !this.centermassTexture) return if (!points.previousPositionTexture || points.previousPositionTexture.destroyed) return if (!points.velocityFbo || points.velocityFbo.destroyed) return @@ -184,11 +167,6 @@ export class ForceCenter extends CoreModule { clearColor: [0, 0, 0, 0], }) - this.calculateUniformStore.setUniforms({ - calculateCentermassUniforms: { - pointsTextureSize: store.pointsTextureSize ?? 0, - }, - }) // Update texture bindings dynamically this.calculateCentermassCommand.setBindings({ positionsTexture: points.previousPositionTexture, @@ -244,8 +222,6 @@ export class ForceCenter extends CoreModule { this.centermassTexture = undefined // 4. Destroy UniformStores (Models already destroyed their managed uniform buffers) - this.calculateUniformStore?.destroy() - this.calculateUniformStore = undefined this.forceUniformStore?.destroy() this.forceUniformStore = undefined diff --git a/src/modules/ForceCollision/build-grid.vert b/src/modules/ForceCollision/build-grid.vert index ac8abba9..436e028a 100644 --- a/src/modules/ForceCollision/build-grid.vert +++ b/src/modules/ForceCollision/build-grid.vert @@ -7,18 +7,15 @@ uniform sampler2D exitTexture; #ifdef USE_UNIFORM_BUFFERS layout(std140) uniform buildGridUniforms { - float pointsTextureSize; float gridTextureSize; float cellSize; vec2 gridOffset; // Offset for multi-pass (0-1 range, multiplied by cellSize) } buildGrid; -#define pointsTextureSize buildGrid.pointsTextureSize #define gridTextureSize buildGrid.gridTextureSize #define cellSize buildGrid.cellSize #define gridOffset buildGrid.gridOffset #else -uniform float pointsTextureSize; uniform float gridTextureSize; uniform float cellSize; uniform vec2 gridOffset; @@ -29,17 +26,19 @@ in vec2 pointIndices; out vec4 cellData; // xy = position, z = size, w = count (1.0) void main() { + ivec2 pointTexel = ivec2(pointIndices); + // Absent points must not enter the grid — a NaN position bins to a NaN cell and // poisons the accumulated position/size sum for every point in that cell. (exit.g = absent) - vec4 exitStatus = texture(exitTexture, pointIndices / pointsTextureSize); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); if (exitStatus.g > 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; } - vec4 pointPosition = texture(positionsTexture, pointIndices / pointsTextureSize); - vec4 pointSize = texture(sizeTexture, pointIndices / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); + vec4 pointSize = texelFetch(sizeTexture, pointTexel, 0); // Output: position sum, size sum, count cellData = vec4(pointPosition.xy, pointSize.r, 1.0); diff --git a/src/modules/ForceCollision/force-collision-spatial.frag b/src/modules/ForceCollision/force-collision-spatial.frag index 0aa5e64a..3354e009 100644 --- a/src/modules/ForceCollision/force-collision-spatial.frag +++ b/src/modules/ForceCollision/force-collision-spatial.frag @@ -7,7 +7,6 @@ uniform sampler2D gridTexture; #ifdef USE_UNIFORM_BUFFERS layout(std140) uniform forceCollisionUniforms { - float pointsTextureSize; float gridTextureSize; float cellSize; float alpha; @@ -18,7 +17,6 @@ layout(std140) uniform forceCollisionUniforms { vec2 gridOffset; // Must match the offset used when building the grid } forceCollision; -#define pointsTextureSize forceCollision.pointsTextureSize #define gridTextureSize forceCollision.gridTextureSize #define cellSize forceCollision.cellSize #define alpha forceCollision.alpha @@ -28,7 +26,6 @@ layout(std140) uniform forceCollisionUniforms { #define pointsNumber forceCollision.pointsNumber #define gridOffset forceCollision.gridOffset #else -uniform float pointsTextureSize; uniform float gridTextureSize; uniform float cellSize; uniform float alpha; @@ -39,11 +36,12 @@ uniform float pointsNumber; uniform vec2 gridOffset; #endif -in vec2 textureCoords; out vec4 fragColor; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec4 velocity = vec4(0.0); // Get current point's index @@ -56,7 +54,7 @@ void main() { } // Get current point's size for collision radius - vec4 currentSizeData = texture(sizeTexture, textureCoords); + vec4 currentSizeData = texelFetch(sizeTexture, pointTexel, 0); float currentSize = currentSizeData.r; float currentCollisionRadius = (collisionRadius > 0.0 ? collisionRadius : currentSize * 0.5) + collisionPadding; @@ -86,25 +84,31 @@ void main() { continue; } - // Sample the grid cell - vec2 gridCoord = (vec2(neighborCellX, neighborCellY) + 0.5) / gridTextureSize; - vec4 cellData = texture(gridTexture, gridCoord); + // Sample the grid cell (the bounds check above keeps the fetch in range) + ivec2 cell = ivec2(neighborCellX, neighborCellY); + vec4 cellData = texelFetch(gridTexture, cell, 0); float cellCount = cellData.w; if (cellCount < 0.5) continue; // Empty cell - // Scale force by number of points in cell - // Subtract 1 if this is our own cell to avoid self-collision + // The own cell's sums include this point, which drags the average toward + // it and halves the measured distance for a two-point cell. Remove the + // self-contribution before averaging, and skip a cell holding only self. float effectiveCount = cellCount; + vec2 sumPos = cellData.xy; + float sumSize = cellData.z; if (dx == 0 && dy == 0) { - effectiveCount = max(0.0, cellCount - 1.0); + effectiveCount = cellCount - 1.0; + sumPos -= currentPos; + sumSize -= currentSize; } + if (effectiveCount < 0.5) continue; totalNeighbors += effectiveCount; - // Get average position and size in this cell - vec2 avgPos = cellData.xy / cellCount; - float avgSize = cellData.z / cellCount; + // Average position and size of the *other* points in this cell + vec2 avgPos = sumPos / effectiveCount; + float avgSize = sumSize / effectiveCount; float otherCollisionRadius = (collisionRadius > 0.0 ? collisionRadius : avgSize * 0.5) + collisionPadding; // Calculate combined collision radius diff --git a/src/modules/ForceCollision/index.ts b/src/modules/ForceCollision/index.ts index d5fa4330..e36099cb 100644 --- a/src/modules/ForceCollision/index.ts +++ b/src/modules/ForceCollision/index.ts @@ -34,7 +34,6 @@ export class ForceCollision extends CoreModule { private buildGridUniformStore: UniformStore<{ buildGridUniforms: { - pointsTextureSize: number; gridTextureSize: number; cellSize: number; gridOffset: [number, number]; @@ -43,7 +42,6 @@ export class ForceCollision extends CoreModule { private forceUniformStore: UniformStore<{ forceCollisionUniforms: { - pointsTextureSize: number; gridTextureSize: number; cellSize: number; alpha: number; @@ -78,17 +76,22 @@ export class ForceCollision extends CoreModule { const collisionPadding = config.simulationCollisionPadding ?? 0 const effectiveRadius = (collisionRadius > 0 ? collisionRadius : maxSize * 0.5) + collisionPadding - // Cell size = collision radius (smaller cells = better accuracy). - // We use multiple offset passes to catch boundary collisions. - this.cellSize = Math.max(effectiveRadius, 8) + // Two touching points interact up to 2 × effectiveRadius apart, and the 3x3 + // neighbourhood scan only reaches one cell of separation, so the cell must + // span the full interaction range. The offset passes shuffle cell alignment + // to catch boundary collisions; they do not extend the search radius. + this.cellSize = Math.max(effectiveRadius * 2, 8) - // Grid texture size = space size / cell size, clamped to reasonable values + // Grid dimension from that cell size, capped by texture size. Rounding down + // is what keeps the fitted cell at or above the interaction range — rounding + // up divides the space into cells smaller than it. A large radius therefore + // yields a coarse grid: the cell size is the constraint, not the cell count. this.gridTextureSize = Math.min( 512, - Math.max(32, Math.ceil(store.adjustedSpaceSize / this.cellSize)) + Math.max(1, Math.floor(store.adjustedSpaceSize / this.cellSize)) ) - // Recalculate cell size to fit the grid evenly + // Recalculate cell size to fit the grid evenly (only ever grows it) this.cellSize = store.adjustedSpaceSize / this.gridTextureSize // Allocate one grid framebuffer per offset pass. These are scratch buffers @@ -168,7 +171,6 @@ export class ForceCollision extends CoreModule { this.buildGridUniformStore ||= new UniformStore(device, { buildGridUniforms: { uniformTypes: { - pointsTextureSize: 'f32', gridTextureSize: 'f32', cellSize: 'f32', gridOffset: 'vec2', @@ -211,7 +213,6 @@ export class ForceCollision extends CoreModule { this.forceUniformStore ||= new UniformStore(device, { forceCollisionUniforms: { uniformTypes: { - pointsTextureSize: 'f32', gridTextureSize: 'f32', cellSize: 'f32', alpha: 'f32', @@ -292,7 +293,6 @@ export class ForceCollision extends CoreModule { this.buildGridUniformStore.setUniforms({ buildGridUniforms: { - pointsTextureSize: store.pointsTextureSize ?? 0, gridTextureSize: this.gridTextureSize, cellSize: this.cellSize, gridOffset, @@ -325,7 +325,6 @@ export class ForceCollision extends CoreModule { this.forceUniformStore.setUniforms({ forceCollisionUniforms: { - pointsTextureSize: store.pointsTextureSize ?? 0, gridTextureSize: this.gridTextureSize, cellSize: this.cellSize, alpha: store.alpha, diff --git a/src/modules/ForceGravity/force-gravity.frag b/src/modules/ForceGravity/force-gravity.frag index b075f834..0e3a4391 100644 --- a/src/modules/ForceGravity/force-gravity.frag +++ b/src/modules/ForceGravity/force-gravity.frag @@ -19,11 +19,12 @@ uniform float spaceSize; uniform float alpha; #endif -in vec2 textureCoords; out vec4 fragColor; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec4 velocity = vec4(0.0); diff --git a/src/modules/ForceLink/force-spring.ts b/src/modules/ForceLink/force-spring.ts index 93642842..c9c1e645 100644 --- a/src/modules/ForceLink/force-spring.ts +++ b/src/modules/ForceLink/force-spring.ts @@ -14,7 +14,6 @@ layout(std140) uniform forceLinkUniforms { float linkSpring; float linkDistance; vec2 linkDistRandomVariationRange; - float pointsTextureSize; float linksTextureSize; float alpha; } forceLink; @@ -22,28 +21,27 @@ layout(std140) uniform forceLinkUniforms { #define linkSpring forceLink.linkSpring #define linkDistance forceLink.linkDistance #define linkDistRandomVariationRange forceLink.linkDistRandomVariationRange -#define pointsTextureSize forceLink.pointsTextureSize #define linksTextureSize forceLink.linksTextureSize #define alpha forceLink.alpha #else uniform float linkSpring; uniform float linkDistance; uniform vec2 linkDistRandomVariationRange; -uniform float pointsTextureSize; uniform float linksTextureSize; uniform float alpha; #endif -in vec2 textureCoords; out vec4 fragColor; const float MAX_LINKS = ${maxLinks}.0; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec4 velocity = vec4(0.0); - vec4 linkInfo = texture(linkInfoTexture, textureCoords); + vec4 linkInfo = texelFetch(linkInfoTexture, pointTexel, 0); float iCount = linkInfo.r; float jCount = linkInfo.g; float linkAmount = linkInfo.b; @@ -54,10 +52,10 @@ void main() { iCount = 0.0; jCount += 1.0; } - vec2 linkTextureIndex = (vec2(iCount, jCount) + 0.5) / linksTextureSize; - vec4 connectedPointIndex = texture(linkIndicesTexture, linkTextureIndex); - vec4 biasAndStrength = texture(linkPropertiesTexture, linkTextureIndex); - vec4 randomMinDistance = texture(linkRandomDistanceTexture, linkTextureIndex); + ivec2 linkTexel = ivec2(iCount, jCount); + vec4 connectedPointIndex = texelFetch(linkIndicesTexture, linkTexel, 0); + vec4 biasAndStrength = texelFetch(linkPropertiesTexture, linkTexel, 0); + vec4 randomMinDistance = texelFetch(linkRandomDistanceTexture, linkTexel, 0); float bias = biasAndStrength.r; float strength = biasAndStrength.g; float randomMinLinkDist = randomMinDistance.r * (linkDistRandomVariationRange.g - linkDistRandomVariationRange.r) + linkDistRandomVariationRange.r; @@ -65,14 +63,17 @@ void main() { iCount += 1.0; + // linkIndicesTexture stores whole texel coordinates, so truncating is exact. + ivec2 connectedTexel = ivec2(connectedPointIndex.rg); + // Skip a link to an absent point — its position would poison the spring // force. (exit.G = current absence) - vec4 connectedExit = texture(exitTexture, (connectedPointIndex.rg + 0.5) / pointsTextureSize); + vec4 connectedExit = texelFetch(exitTexture, connectedTexel, 0); if (connectedExit.g > 0.5) { continue; } - vec4 connectedPointPosition = texture(positionsTexture, (connectedPointIndex.rg + 0.5) / pointsTextureSize); + vec4 connectedPointPosition = texelFetch(positionsTexture, connectedTexel, 0); float x = connectedPointPosition.x - (pointPosition.x + velocity.x); float y = connectedPointPosition.y - (pointPosition.y + velocity.y); float l = sqrt(x * x + y * y); diff --git a/src/modules/ForceLink/index.ts b/src/modules/ForceLink/index.ts index 62b01c59..6c930836 100644 --- a/src/modules/ForceLink/index.ts +++ b/src/modules/ForceLink/index.ts @@ -27,7 +27,6 @@ export class ForceLink extends CoreModule { linkSpring: number; linkDistance: number; linkDistRandomVariationRange: [number, number]; - pointsTextureSize: number; linksTextureSize: number; alpha: number; }; @@ -65,8 +64,12 @@ export class ForceLink extends CoreModule { // Prevent division by zero const bias = degreeSum !== 0 ? degree / degreeSum : 0.5 const minDegree = Math.min(degree, connectedDegree) - // Prevent division by zero - let strength = data.linkStrength?.[initialLinkIndex] ?? (1 / Math.max(minDegree, 1)) + // Strength must reach the texture finite and non-negative — Math.sqrt emits + // NaN otherwise, and the position clamp downstream lands the point at (0, 0). + let strength = data.linkStrength?.[initialLinkIndex] + if (strength === undefined || !Number.isFinite(strength) || strength < 0) { + strength = 1 / Math.max(minDegree, 1) // max() prevents division by zero + } strength = Math.sqrt(strength) linkBiasAndStrengthState[linkIndex * 4 + 0] = bias linkBiasAndStrengthState[linkIndex * 4 + 1] = strength @@ -182,7 +185,6 @@ export class ForceLink extends CoreModule { linkSpring: 'f32', linkDistance: 'f32', linkDistRandomVariationRange: 'vec2', - pointsTextureSize: 'f32', linksTextureSize: 'f32', alpha: 'f32', }, @@ -238,7 +240,6 @@ export class ForceLink extends CoreModule { linkSpring: this.config.simulationLinkSpring, linkDistance: this.config.simulationLinkDistance, linkDistRandomVariationRange: ensureVec2(this.config.simulationLinkDistRandomVariationRange, [0, 0]), - pointsTextureSize: store.pointsTextureSize, linksTextureSize: store.linksTextureSize, alpha: store.alpha, }, diff --git a/src/modules/ForceManyBody/build-nearfield-slots.vert b/src/modules/ForceManyBody/build-nearfield-slots.vert index b807cad1..4779f3f0 100644 --- a/src/modules/ForceManyBody/build-nearfield-slots.vert +++ b/src/modules/ForceManyBody/build-nearfield-slots.vert @@ -44,10 +44,12 @@ in vec2 pointIndices; out vec2 slotData; // [point index, hash] void main() { + ivec2 pointTexel = ivec2(pointIndices); + // Absent points must not be captured as neighbors — a NaN position bins to an // undefined cell and its distance poisons the force of every point sampling // that slot. Same guard as calculate-level.vert. (exit.G = absent) - vec4 exitStatus = texture(exitTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); if (exitStatus.g > 0.5) { slotData = vec2(-1.0, 1.0); gl_Position = vec4(2.0, 2.0, 2.0, 1.0); @@ -55,7 +57,7 @@ void main() { return; } - vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); float index = pointIndices.y * pointsTextureSize + pointIndices.x; // Per-tick random ordering via an integer hash (lowbias32). A fract(sin(...)) diff --git a/src/modules/ForceManyBody/calculate-level.vert b/src/modules/ForceManyBody/calculate-level.vert index 66b0a890..fa86ea29 100644 --- a/src/modules/ForceManyBody/calculate-level.vert +++ b/src/modules/ForceManyBody/calculate-level.vert @@ -10,16 +10,13 @@ uniform sampler2D exitTexture; #ifdef USE_UNIFORM_BUFFERS layout(std140) uniform calculateLevelsPreciseUniforms { - float pointsTextureSize; float levelGridSize; float cellSize; } calculateLevelsPrecise; -#define pointsTextureSize calculateLevelsPrecise.pointsTextureSize #define levelGridSize calculateLevelsPrecise.levelGridSize #define cellSize calculateLevelsPrecise.cellSize #else -uniform float pointsTextureSize; uniform float levelGridSize; uniform float cellSize; #endif @@ -31,16 +28,18 @@ out vec4 vColor; void main() { vColor = vec4(0.0); + ivec2 pointTexel = ivec2(pointIndices); + // Absent points must not enter the grid — a NaN position bins to a NaN cell and // poisons the centermass that drives repulsion for every point. (exit.G = absent) - vec4 exitStatus = texture(exitTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); if (exitStatus.g > 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; } - vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vColor = vec4(pointPosition.rg, 1.0, 0.0); // The clamp must match the force shaders exactly, or boundary points fall out diff --git a/src/modules/ForceManyBody/force-level.frag b/src/modules/ForceManyBody/force-level.frag index a2e1172f..bb213d1c 100644 --- a/src/modules/ForceManyBody/force-level.frag +++ b/src/modules/ForceManyBody/force-level.frag @@ -36,7 +36,6 @@ uniform float alpha; uniform float repulsion; #endif -in vec2 textureCoords; out vec4 fragColor; // Repulsion from one cell's center of mass — a d3-style clamped @@ -57,7 +56,9 @@ vec2 cellVelocity(ivec2 cell, vec2 position) { } void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec2 position = pointPosition.rg; int gridSize = int(levelGridSize); diff --git a/src/modules/ForceManyBody/force-nearfield.frag b/src/modules/ForceManyBody/force-nearfield.frag index bc4d17de..73d62d4d 100644 --- a/src/modules/ForceManyBody/force-nearfield.frag +++ b/src/modules/ForceManyBody/force-nearfield.frag @@ -1,5 +1,8 @@ #version 300 es precision highp float; +// Fragment shaders default int to mediump, guaranteed only to 32767 — +// point indices go far higher. +precision highp int; // Near-field pass of the precise grid repulsion (P3M-style). After the finest // level pass, the only un-accumulated region is the 3×3 neighborhood of the @@ -55,7 +58,6 @@ uniform float alpha; uniform float repulsion; #endif -in vec2 textureCoords; out vec4 fragColor; // Same clamped inverse-distance falloff as the level passes (must stay identical). @@ -97,11 +99,13 @@ vec2 slotVelocity(vec2 slot, vec2 position, float selfIndex, vec2 randomDir, ino } void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec2 position = pointPosition.rg; // One fragment per point: the fragment's pixel is the point's texel. float selfIndex = floor(gl_FragCoord.y) * pointsTextureSize + floor(gl_FragCoord.x); - vec4 random = texture(randomValues, textureCoords); + vec4 random = texelFetch(randomValues, pointTexel, 0); int gridSize = int(levelGridSize); ivec2 pointCell = clamp(ivec2(floor(position / cellSize)), ivec2(0), ivec2(gridSize - 1)); diff --git a/src/modules/ForceManyBody/index.ts b/src/modules/ForceManyBody/index.ts index d30fe51d..c3af6ab6 100644 --- a/src/modules/ForceManyBody/index.ts +++ b/src/modules/ForceManyBody/index.ts @@ -83,7 +83,6 @@ export class ForceManyBody extends CoreModule { private calculateLevelsUniformStore: UniformStore<{ calculateLevelsPreciseUniforms: { - pointsTextureSize: number; levelGridSize: number; cellSize: number; }; @@ -198,12 +197,10 @@ export class ForceManyBody extends CoreModule { calculateLevelsPreciseUniforms: { uniformTypes: { // Order MUST match shader declaration order (std140 layout) - pointsTextureSize: 'f32', levelGridSize: 'f32', cellSize: 'f32', }, defaultUniforms: { - pointsTextureSize: store.pointsTextureSize, levelGridSize: 0, cellSize: 0, }, @@ -480,7 +477,6 @@ export class ForceManyBody extends CoreModule { this.calculateLevelsUniformStore.setUniforms({ calculateLevelsPreciseUniforms: { - pointsTextureSize: store.pointsTextureSize ?? 0, levelGridSize: target.gridSize, // Computed per level from the space size so the power-of-two halving // chain stays bit-exact between levels (the coverage invariant relies on it). diff --git a/src/modules/ForceMouse/force-mouse.frag b/src/modules/ForceMouse/force-mouse.frag index ca44a6da..4fd7bc4c 100644 --- a/src/modules/ForceMouse/force-mouse.frag +++ b/src/modules/ForceMouse/force-mouse.frag @@ -16,11 +16,12 @@ uniform float repulsion; uniform vec2 mousePos; #endif -in vec2 textureCoords; out vec4 fragColor; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec4 velocity = vec4(0.0); vec2 mouse = mousePos; // Move particles away from the mouse position using a repulsive force diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index 14fa7040..3165ff5d 100644 --- a/src/modules/GraphData/index.ts +++ b/src/modules/GraphData/index.ts @@ -111,6 +111,14 @@ export class GraphData { } public updatePoints (): void { + // Positions must hold whole [x, y] pairs: an odd length makes `pointsNumber` + // fractional, which the adjacency and degree builds pass to `new Array()`. + // `subarray` is a view over the same buffer — the caller's array is not edited. + if (this.inputPointPositions !== undefined && this.inputPointPositions.length % 2 !== 0) { + console.warn(`Invalid point positions length: ${this.inputPointPositions.length}. The array must hold [x, y] pairs — the trailing value was ignored.`) + this.inputPointPositions = this.inputPointPositions.subarray(0, this.inputPointPositions.length - 1) + } + // Don't sync the same positions twice — it breaks animations when points are added or removed. if (this.pointPositions === this.inputPointPositions) return @@ -271,7 +279,25 @@ export class GraphData { } } + /** + * True when `index` addresses a real point. Link endpoints come straight from + * the caller, so they may be out of range, negative or fractional. + */ + public isPointIndex (index: number | undefined): index is number { + return index !== undefined && Number.isInteger(index) && + index >= 0 && index < (this.pointsNumber ?? 0) + } + public updateLinks (): void { + // Links must hold whole [source, target] pairs: an odd length makes + // `linksNumber` fractional, and `new Array(linksNumber)` in `updateArrows` + // throws from inside the deferred render, leaving the graph blank with no + // error surfaced. `subarray` is a view — the caller's array is not edited. + if (this.inputLinks !== undefined && this.inputLinks.length % 2 !== 0) { + console.warn(`Invalid links length: ${this.inputLinks.length}. The array must hold [source, target] pairs — the trailing value was ignored.`) + this.inputLinks = this.inputLinks.subarray(0, this.inputLinks.length - 1) + } + this.links = this.inputLinks } @@ -443,10 +469,9 @@ export class GraphData { */ public getNeighboringPointIndices (pointIndices: number | number[]): number[] { const indices = Array.isArray(pointIndices) ? pointIndices : [pointIndices] - const pointsNumber = this.pointsNumber ?? 0 const result = new Set() for (const index of indices) { - if (index < 0 || index >= pointsNumber) continue + if (!this.isPointIndex(index)) continue for (const [pointIndex] of this.sourceIndexToTargetIndices?.[index] ?? []) result.add(pointIndex) for (const [pointIndex] of this.targetIndexToSourceIndices?.[index] ?? []) result.add(pointIndex) } @@ -461,11 +486,10 @@ export class GraphData { */ public getConnectedLinkIndices (pointIndices: number | number[]): number[] { const indices = Array.isArray(pointIndices) ? pointIndices : [pointIndices] - const pointsNumber = this.pointsNumber ?? 0 const indexSet = new Set(indices) const result = new Set() for (const index of indexSet) { - if (index < 0 || index >= pointsNumber) continue + if (!this.isPointIndex(index)) continue for (const [targetIndex, linkIndex] of this.sourceIndexToTargetIndices?.[index] ?? []) { if (indexSet.has(targetIndex)) result.add(linkIndex) } @@ -484,11 +508,16 @@ export class GraphData { if (this.links === undefined) return [] const linksNumber = this.linksNumber ?? 0 for (const linkIndex of indices) { - if (linkIndex < 0 || linkIndex >= linksNumber) continue + // A fractional index reads one endpoint from each of two neighbouring links. + if (!Number.isInteger(linkIndex) || linkIndex < 0 || linkIndex >= linksNumber) continue const sourceIndex = this.links[linkIndex * 2] const targetIndex = this.links[linkIndex * 2 + 1] - if (sourceIndex !== undefined) result.add(sourceIndex) - if (targetIndex !== undefined) result.add(targetIndex) + // Read straight from `links`, so the endpoint check the adjacency build + // applies has to be repeated here: a link with one invalid endpoint + // contributes neither. + if (!this.isPointIndex(sourceIndex) || !this.isPointIndex(targetIndex)) continue + result.add(sourceIndex) + result.add(targetIndex) } return [...result] } @@ -505,12 +534,16 @@ export class GraphData { for (let i = 0; i < this.linksNumber; i++) { const sourceIndex = this.links[i * 2] const targetIndex = this.links[i * 2 + 1] - if (sourceIndex !== undefined && targetIndex !== undefined) { - if (this.sourceIndexToTargetIndices[sourceIndex] === undefined) this.sourceIndexToTargetIndices[sourceIndex] = [] - this.sourceIndexToTargetIndices[sourceIndex]?.push([targetIndex, i]) - - if (this.targetIndexToSourceIndices[targetIndex] === undefined) this.targetIndexToSourceIndices[targetIndex] = [] - this.targetIndexToSourceIndices[targetIndex]?.push([sourceIndex, i]) + // Both endpoints must be real points: an out-of-range index would extend + // these arrays past the point count and come back out of + // `getNeighboringPointIndices` as a point the caller cannot look up. + // Skipped rather than dropped, so link indices stay the caller's own. + if (this.isPointIndex(sourceIndex) && this.isPointIndex(targetIndex)) { + this.sourceIndexToTargetIndices[sourceIndex] ??= [] + this.sourceIndexToTargetIndices[sourceIndex].push([targetIndex, i]) + + this.targetIndexToSourceIndices[targetIndex] ??= [] + this.targetIndexToSourceIndices[targetIndex].push([sourceIndex, i]) } } } diff --git a/src/modules/Lines/draw-curve-line.frag b/src/modules/Lines/draw-curve-line.frag index 20dcff99..c1b075c9 100644 --- a/src/modules/Lines/draw-curve-line.frag +++ b/src/modules/Lines/draw-curve-line.frag @@ -24,6 +24,7 @@ layout(std140) uniform drawLineFragmentUniforms { float linkColorInterpolateFromEndpoints; float hoveredLinkIndex; vec4 hoveredLinkColor; + float linkBlending; } drawLineFrag; #define renderMode drawLineFrag.renderMode @@ -32,6 +33,7 @@ layout(std140) uniform drawLineFragmentUniforms { #define linkColorInterpolateFromEndpoints drawLineFrag.linkColorInterpolateFromEndpoints #define hoveredLinkIndex drawLineFrag.hoveredLinkIndex #define hoveredLinkColor drawLineFrag.hoveredLinkColor +#define linkBlending drawLineFrag.linkBlending #else // renderMode: 0.0 = normal rendering, 1.0 = index buffer rendering for picking uniform float renderMode; @@ -40,6 +42,7 @@ uniform float linkDashGap; uniform float linkColorInterpolateFromEndpoints; uniform float hoveredLinkIndex; uniform vec4 hoveredLinkColor; +uniform float linkBlending; #endif out vec4 fragColor; @@ -62,7 +65,8 @@ float strokeMask(float phase, float on, float period, float aa) { } void main() { - float opacity = 1.0; + // Geometric coverage only (stroke / arrow / dash). Color alpha is applied after. + float coverage = 1.0; vec3 color = rgbaColor.rgb; // Arrowhead extent along the link (pos.x space) — used by the arrow rendering @@ -78,17 +82,17 @@ void main() { if (useArrow > 0.5) { float arrowWidthDelta = arrowWidthFactor / 2.0; - float linkOpacity = rgbaColor.a * smoothstep(0.5 - arrowWidthDelta, 0.5 - arrowWidthDelta - smoothing / 2.0, abs(pos.y)); - float arrowOpacity = 1.0; + float linkCoverage = smoothstep(0.5 - arrowWidthDelta, 0.5 - arrowWidthDelta - smoothing / 2.0, abs(pos.y)); + float arrowCoverage = 1.0; if (pos.x > start_arrow && pos.x < start_arrow + arrowLength) { float xmapped = map(pos.x, start_arrow, end_arrow, 0.0, 1.0); - arrowOpacity = rgbaColor.a * smoothstep(xmapped - smoothing, xmapped, map(abs(pos.y), 0.5, 0.0, 0.0, 1.0)); - if (linkOpacity != arrowOpacity) { - linkOpacity = max(linkOpacity, arrowOpacity); + arrowCoverage = smoothstep(xmapped - smoothing, xmapped, map(abs(pos.y), 0.5, 0.0, 0.0, 1.0)); + if (linkCoverage != arrowCoverage) { + linkCoverage = max(linkCoverage, arrowCoverage); } } - opacity = linkOpacity; - } else opacity = rgbaColor.a * smoothstep(0.5, 0.5 - smoothing, abs(pos.y)); + coverage = linkCoverage; + } else coverage = smoothstep(0.5, 0.5 - smoothing, abs(pos.y)); // Dashed / dotted stroke patterns. Applied to the visible pass only (renderMode == 0.0) // so that gaps stay fully pickable in the index pass. The arrowhead region is left solid. @@ -101,7 +105,7 @@ void main() { if (vLinkStyle == LINK_STYLE_DASHED) { float period = max(linkDashLength + linkDashGap, 0.001); float aa = max(fwidth(phase), 1e-4); - opacity *= strokeMask(phase, linkDashLength, period, aa); + coverage *= strokeMask(phase, linkDashLength, period, aa); } else { // Dotted: round dots sized to the stroke width, spaced by diameter + gap. float diameter = vLinkDashWidth; @@ -110,11 +114,13 @@ void main() { float localY = pos.y * vLinkDashWidth; float r = length(vec2(localX, localY)); float aa = max(fwidth(r), 1e-4); - opacity *= 1.0 - smoothstep(diameter * 0.5 - aa, diameter * 0.5 + aa, r); + coverage *= 1.0 - smoothstep(diameter * 0.5 - aa, diameter * 0.5 + aa, r); } } } + float opacity = rgbaColor.a * coverage; + // Apply hover color if this is the hovered link and hover color is defined. // Done last — after the gradient and the dash mask — so hover wins over every // color source (per-link color from the vertex stage and the endpoint gradient @@ -127,6 +133,16 @@ void main() { if (renderMode > 0.0) { if (opacity <= 0.0) discard; fragColor = vec4(linkIndex, 0.0, 0.0, 1.0); - } else fragColor = vec4(color, opacity); + } else if (linkBlending < 0.5) { + // Unblended: any covered fragment is fully opaque. Soft AA fringes would + // otherwise write full RGB with partial alpha; canvas compositing treats that + // as premultiplied and the edge reads brighter than the solid core. + // Discard only zero coverage / fully transparent — do not hard-cut at + // coverage < 0.5, which erases thin strokes whose smoothstep peaks ~0.5. + if (coverage <= 0.0 || opacity <= 0.0) discard; + fragColor = vec4(color, 1.0); + } else { + fragColor = vec4(color, opacity); + } } diff --git a/src/modules/Lines/draw-curve-line.vert b/src/modules/Lines/draw-curve-line.vert index 61e86c4b..6fc2c1f0 100644 --- a/src/modules/Lines/draw-curve-line.vert +++ b/src/modules/Lines/draw-curve-line.vert @@ -20,7 +20,6 @@ uniform sampler2D pointColorsTexture; #ifdef USE_UNIFORM_BUFFERS layout(std140) uniform drawLineUniforms { mat4 transformationMatrix; - float pointsTextureSize; float widthScale; float linkArrowsSizeScale; float spaceSize; @@ -50,7 +49,6 @@ layout(std140) uniform drawLineUniforms { } drawLine; #define transformationMatrix drawLine.transformationMatrix -#define pointsTextureSize drawLine.pointsTextureSize #define widthScale drawLine.widthScale #define linkArrowsSizeScale drawLine.linkArrowsSizeScale #define spaceSize drawLine.spaceSize @@ -79,7 +77,6 @@ layout(std140) uniform drawLineUniforms { #define linkColorInterpolateFromEndpoints drawLine.linkColorInterpolateFromEndpoints #else uniform mat3 transformationMatrix; -uniform float pointsTextureSize; uniform float widthScale; uniform float linkArrowsSizeScale; uniform float spaceSize; @@ -171,11 +168,11 @@ void main() { linkIndex = linkIndices; vLinkStyle = linkStyle; - vec2 pointTexturePosA = (pointA + 0.5) / pointsTextureSize; - vec2 pointTexturePosB = (pointB + 0.5) / pointsTextureSize; + ivec2 pointTexelA = ivec2(pointA); + ivec2 pointTexelB = ivec2(pointB); - vec4 pointPositionA = texture(positionsTexture, pointTexturePosA); - vec4 pointPositionB = texture(positionsTexture, pointTexturePosB); + vec4 pointPositionA = texelFetch(positionsTexture, pointTexelA, 0); + vec4 pointPositionB = texelFetch(positionsTexture, pointTexelB, 0); vec2 a = pointPositionA.xy; vec2 b = pointPositionB.xy; @@ -190,8 +187,8 @@ void main() { // Exit status of both endpoints (R = previous absence, G = current absence). A link // is only as present as its endpoints. - vec4 exitStatusA = texture(exitTexture, pointTexturePosA); - vec4 exitStatusB = texture(exitTexture, pointTexturePosB); + vec4 exitStatusA = texelFetch(exitTexture, pointTexelA, 0); + vec4 exitStatusB = texelFetch(exitTexture, pointTexelB, 0); // Picking must not report a link to a removed point even mid-fade — same rule as // point picking, which excludes on current absence. @@ -217,8 +214,8 @@ void main() { // The texture mirrors GraphData.pointColors, so channels may be NaN ("use the // default") — resolve them with the endpoint's exit ramp, like the point draw. if (linkColorInterpolateFromEndpoints > 0.5) { - vEndpointColorA = resolveColor(texture(pointColorsTexture, pointTexturePosA), exitA); - vEndpointColorB = resolveColor(texture(pointColorsTexture, pointTexturePosB), exitB); + vEndpointColorA = resolveColor(texelFetch(pointColorsTexture, pointTexelA, 0), exitA); + vEndpointColorB = resolveColor(texelFetch(pointColorsTexture, pointTexelB, 0), exitB); } // Calculate direction vector and its perpendicular @@ -315,10 +312,10 @@ void main() { // Apply greyed-out opacity from link status texture if (isLinkHighlightingActive > 0.0 && linkStatusTextureSize > 0.0) { - float texX = mod(linkIndices, linkStatusTextureSize); - float texY = floor(linkIndices / linkStatusTextureSize); - vec2 linkStatusCoord = (vec2(texX, texY) + 0.5) / linkStatusTextureSize; - vec4 linkStatusValue = texture(linkStatus, linkStatusCoord); + int statusTexSize = int(linkStatusTextureSize); + int statusIndex = int(linkIndices); + ivec2 statusTexel = ivec2(statusIndex % statusTexSize, statusIndex / statusTexSize); + vec4 linkStatusValue = texelFetch(linkStatus, statusTexel, 0); if (linkStatusValue.r > 0.0) { opacity *= greyoutOpacity; } diff --git a/src/modules/Lines/fill-sampled-links.vert b/src/modules/Lines/fill-sampled-links.vert index 88ddd9b8..71086640 100644 --- a/src/modules/Lines/fill-sampled-links.vert +++ b/src/modules/Lines/fill-sampled-links.vert @@ -12,7 +12,6 @@ uniform sampler2D exitTexture; #ifdef USE_UNIFORM_BUFFERS layout(std140) uniform fillSampledLinksUniforms { - float pointsTextureSize; mat4 transformationMatrix; float spaceSize; vec2 screenSize; @@ -21,7 +20,6 @@ layout(std140) uniform fillSampledLinksUniforms { float curvedLinkSegments; } fillSampledLinks; -#define pointsTextureSize fillSampledLinks.pointsTextureSize #define transformationMatrix fillSampledLinks.transformationMatrix #define spaceSize fillSampledLinks.spaceSize #define screenSize fillSampledLinks.screenSize @@ -29,7 +27,6 @@ layout(std140) uniform fillSampledLinksUniforms { #define curvedLinkControlPointDistance fillSampledLinks.curvedLinkControlPointDistance #define curvedLinkSegments fillSampledLinks.curvedLinkSegments #else -uniform float pointsTextureSize; uniform float spaceSize; uniform vec2 screenSize; uniform float curvedWeight; @@ -41,16 +38,19 @@ uniform mat3 transformationMatrix; out vec4 rgba; void main() { + ivec2 pointTexelA = ivec2(pointA); + ivec2 pointTexelB = ivec2(pointB); + // Skip a link touching an absent (faded-out) point. exit.G = current absence. - if (texture(exitTexture, (pointA + 0.5) / pointsTextureSize).g > 0.5 || - texture(exitTexture, (pointB + 0.5) / pointsTextureSize).g > 0.5) { + if (texelFetch(exitTexture, pointTexelA, 0).g > 0.5 || + texelFetch(exitTexture, pointTexelB, 0).g > 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; } - vec4 posA = texture(positionsTexture, (pointA + 0.5) / pointsTextureSize); - vec4 posB = texture(positionsTexture, (pointB + 0.5) / pointsTextureSize); + vec4 posA = texelFetch(positionsTexture, pointTexelA, 0); + vec4 posB = texelFetch(positionsTexture, pointTexelB, 0); vec2 a = posA.rg; vec2 b = posB.rg; diff --git a/src/modules/Lines/index.ts b/src/modules/Lines/index.ts index a7929a10..7176335b 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -73,7 +73,6 @@ export class Lines extends CoreModule { private shouldAnimatePositions = false private fillSampledLinksUniformStore: UniformStore<{ fillSampledLinksUniforms: { - pointsTextureSize: number; transformationMatrix: Mat4Array; spaceSize: number; screenSize: [number, number]; @@ -87,7 +86,6 @@ export class Lines extends CoreModule { private drawLineUniformStore: UniformStore<{ drawLineUniforms: { transformationMatrix: Mat4Array; - pointsTextureSize: number; widthScale: number; linkArrowsSizeScale: number; spaceSize: number; @@ -122,6 +120,7 @@ export class Lines extends CoreModule { linkColorInterpolateFromEndpoints: number; hoveredLinkIndex: number; hoveredLinkColor: [number, number, number, number]; + linkBlending: number; }; }> | undefined @@ -173,7 +172,6 @@ export class Lines extends CoreModule { drawLineUniforms: { uniformTypes: { transformationMatrix: 'mat4x4', - pointsTextureSize: 'f32', widthScale: 'f32', linkArrowsSizeScale: 'f32', spaceSize: 'f32', @@ -203,7 +201,6 @@ export class Lines extends CoreModule { }, defaultUniforms: { transformationMatrix: store.transformationMatrix4x4, - pointsTextureSize: store.pointsTextureSize, widthScale: config.linkWidthScale, linkArrowsSizeScale: config.linkArrowsSizeScale, spaceSize: store.adjustedSpaceSize, @@ -240,6 +237,7 @@ export class Lines extends CoreModule { linkColorInterpolateFromEndpoints: 'f32', hoveredLinkIndex: 'f32', hoveredLinkColor: 'vec4', + linkBlending: 'f32', }, defaultUniforms: { renderMode: 0.0, @@ -248,6 +246,7 @@ export class Lines extends CoreModule { linkColorInterpolateFromEndpoints: config.linkColorInterpolateFromEndpoints ? 1 : 0, hoveredLinkIndex: store.hoveredLinkIndex ?? -1, hoveredLinkColor: ensureVec4(store.hoveredLinkColor, [-1, -1, -1, -1]), + linkBlending: config.linkBlending ? 1 : 0, }, }, }) @@ -260,7 +259,6 @@ export class Lines extends CoreModule { this.fillSampledLinksUniformStore ||= new UniformStore(device, { fillSampledLinksUniforms: { uniformTypes: { - pointsTextureSize: 'f32', transformationMatrix: 'mat4x4', spaceSize: 'f32', screenSize: 'vec2', @@ -269,7 +267,6 @@ export class Lines extends CoreModule { curvedLinkSegments: 'f32', }, defaultUniforms: { - pointsTextureSize: store.pointsTextureSize ?? 0, transformationMatrix: store.transformationMatrix4x4, spaceSize: store.adjustedSpaceSize, screenSize: ensureVec2(store.screenSize, [0, 0]), @@ -335,7 +332,6 @@ export class Lines extends CoreModule { this.drawLineUniformStore.setUniforms({ drawLineUniforms: { transformationMatrix: store.transformationMatrix4x4, - pointsTextureSize: store.pointsTextureSize, widthScale: config.linkWidthScale, linkArrowsSizeScale: config.linkArrowsSizeScale, spaceSize: store.adjustedSpaceSize, @@ -371,6 +367,7 @@ export class Lines extends CoreModule { linkColorInterpolateFromEndpoints: config.linkColorInterpolateFromEndpoints ? 1 : 0, hoveredLinkIndex: store.hoveredLinkIndex ?? -1, hoveredLinkColor: ensureVec4(store.hoveredLinkColor, [-1, -1, -1, -1]), + linkBlending: config.linkBlending ? 1 : 0, }, }) @@ -485,8 +482,15 @@ export class Lines extends CoreModule { const pointBData = new Float32Array(data.linksNumber * 2) for (let i = 0; i < data.linksNumber; i++) { - const fromIndex = data.links[i * 2] as number - const toIndex = data.links[i * 2 + 1] as number + const rawFrom = data.links[i * 2] as number + const rawTo = data.links[i * 2 + 1] as number + // An endpoint that is not a real point would address a texel outside the + // position texture and draw a link to wherever that reads. Collapse the + // link onto one texel instead — zero length renders nothing — keeping the + // instance, so every link index still means what the caller passed. + const isValid = data.isPointIndex(rawFrom) && data.isPointIndex(rawTo) + const fromIndex = isValid ? rawFrom : 0 + const toIndex = isValid ? rawTo : 0 const fromX = fromIndex % store.pointsTextureSize const fromY = Math.floor(fromIndex / store.pointsTextureSize) const toX = toIndex % store.pointsTextureSize @@ -751,7 +755,6 @@ export class Lines extends CoreModule { this.fillSampledLinksFboCommand.setVertexCount(this.data.linksNumber ?? 0) this.fillSampledLinksUniformStore.setUniforms({ fillSampledLinksUniforms: { - pointsTextureSize: this.store.pointsTextureSize ?? 0, transformationMatrix: this.store.transformationMatrix4x4, spaceSize: this.store.adjustedSpaceSize, screenSize: ensureVec2(this.store.screenSize, [0, 0]), @@ -800,7 +803,6 @@ export class Lines extends CoreModule { this.fillSampledLinksFboCommand.setVertexCount(this.data.linksNumber ?? 0) this.fillSampledLinksUniformStore.setUniforms({ fillSampledLinksUniforms: { - pointsTextureSize: this.store.pointsTextureSize ?? 0, transformationMatrix: this.store.transformationMatrix4x4, spaceSize: this.store.adjustedSpaceSize, screenSize: ensureVec2(this.store.screenSize, [0, 0]), @@ -869,7 +871,6 @@ export class Lines extends CoreModule { this.drawLineUniformStore.setUniforms({ drawLineUniforms: { transformationMatrix: store.transformationMatrix4x4, - pointsTextureSize: store.pointsTextureSize, widthScale: config.linkWidthScale, linkArrowsSizeScale: config.linkArrowsSizeScale, spaceSize: store.adjustedSpaceSize, @@ -904,6 +905,8 @@ export class Lines extends CoreModule { linkColorInterpolateFromEndpoints: config.linkColorInterpolateFromEndpoints ? 1 : 0, hoveredLinkIndex: store.hoveredLinkIndex ?? -1, hoveredLinkColor: ensureVec4(store.hoveredLinkColor, [-1, -1, -1, -1]), + // Keep the UBO complete; picking ignores this when renderMode > 0. + linkBlending: config.linkBlending ? 1 : 0, }, }) diff --git a/src/modules/Points/drag-point.frag b/src/modules/Points/drag-point.frag index 1dbb8d94..7978e5eb 100644 --- a/src/modules/Points/drag-point.frag +++ b/src/modules/Points/drag-point.frag @@ -18,12 +18,12 @@ uniform vec2 mousePos; uniform float index; #endif -in vec2 textureCoords; - out vec4 fragColor; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); // Check if a point is being dragged if (index >= 0.0 && index == pointPosition.b) { diff --git a/src/modules/Points/draw-highlighted.vert b/src/modules/Points/draw-highlighted.vert index 81a70d55..18ba821d 100644 --- a/src/modules/Points/draw-highlighted.vert +++ b/src/modules/Points/draw-highlighted.vert @@ -83,19 +83,27 @@ const float relativeRingRadius = 1.3; void main () { vertexPosition = vertexCoord; - vec2 textureCoordinates = vec2(mod(pointIndex, pointsTextureSize), floor(pointIndex / pointsTextureSize)) + 0.5; + int pointTexSize = int(pointsTextureSize); + int pointLinearIndex = int(pointIndex); + // Integer % and / are undefined on a negative or zero operand, and pointIndex + // defaults to -1 (no point highlighted). Nothing to draw in that case anyway. + if (pointLinearIndex < 0 || pointTexSize <= 0) { + gl_Position = vec4(2.0, 2.0, 2.0, 1.0); + return; + } + ivec2 pointTexel = ivec2(pointLinearIndex % pointTexSize, pointLinearIndex / pointTexSize); // Don't draw a highlight/outline for an absent (faded-out) point. exit.G = absent. - if (texture(exitTexture, textureCoordinates / pointsTextureSize).g > 0.5) { + if (texelFetch(exitTexture, pointTexel, 0).g > 0.5) { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); return; } - vec4 pointPosition = texture(positionsTexture, textureCoordinates / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); rgbColor = color.rgb; pointOpacity = color.a * universalPointOpacity; - vec4 greyoutStatus = texture(pointStatus, textureCoordinates / pointsTextureSize); + vec4 greyoutStatus = texelFetch(pointStatus, pointTexel, 0); if (greyoutStatus.r > 0.0) { if (greyoutColor[0] != -1.0) { rgbColor = greyoutColor.rgb; diff --git a/src/modules/Points/draw-points.vert b/src/modules/Points/draw-points.vert index d11e7bb6..188512ce 100644 --- a/src/modules/Points/draw-points.vert +++ b/src/modules/Points/draw-points.vert @@ -134,8 +134,10 @@ vec4 resolveColor(vec4 color, float exitRamp) { } void main() { + ivec2 pointTexel = ivec2(pointIndices); + // Read point status texture: R = greyout, G = outlined - vec4 status = texture(pointStatus, (pointIndices + 0.5) / pointsTextureSize); + vec4 status = texelFetch(pointStatus, pointTexel, 0); isGreyedOut = status.r; isOutlined = status.g; float isHighlighted = (status.r == 0.0) ? 1.0 : 0.0; @@ -157,7 +159,7 @@ void main() { // settled current absence) so an unrelated color/size transition can't replay the // ramp. The caller drives the visual fade via setPointSizes/setPointColors; here // we only remove the point once it is fully gone. - vec4 exitStatus = texture(exitTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); float exit = animatePositions > 0.0 ? mix(exitStatus.r, exitStatus.g, transitionProgress) : exitStatus.g; @@ -169,7 +171,7 @@ void main() { } // Position - vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec2 point = pointPosition.rg; // Transform point position to normalized device coordinates @@ -255,22 +257,20 @@ void main() { if (hasImages <= 0.0 || imageIndex < 0.0 || imageIndex >= imageCount) { imageAtlasUV = vec4(-1.0); } else { - float atlasCoordIndex = imageIndex; - float texX = mod(atlasCoordIndex, imageAtlasCoordsTextureSize); - float texY = floor(atlasCoordIndex / imageAtlasCoordsTextureSize); - vec2 atlasCoordTexCoord = (vec2(texX, texY) + 0.5) / imageAtlasCoordsTextureSize; - vec4 atlasCoords = texture(imageAtlasCoords, atlasCoordTexCoord); + int atlasTexSize = int(imageAtlasCoordsTextureSize); + int atlasCoordIndex = int(imageIndex); + ivec2 atlasTexel = ivec2(atlasCoordIndex % atlasTexSize, atlasCoordIndex / atlasTexSize); + vec4 atlasCoords = texelFetch(imageAtlasCoords, atlasTexel, 0); imageAtlasUV = atlasCoords; } #else if (hasImages <= 0.0 || imageIndex < 0.0 || imageIndex >= imageCount) { imageAtlasUV = vec4(-1.0); } else { - float atlasCoordIndex = imageIndex; - float texX = mod(atlasCoordIndex, imageAtlasCoordsTextureSize); - float texY = floor(atlasCoordIndex / imageAtlasCoordsTextureSize); - vec2 atlasCoordTexCoord = (vec2(texX, texY) + 0.5) / imageAtlasCoordsTextureSize; - vec4 atlasCoords = texture(imageAtlasCoords, atlasCoordTexCoord); + int atlasTexSize = int(imageAtlasCoordsTextureSize); + int atlasCoordIndex = int(imageIndex); + ivec2 atlasTexel = ivec2(atlasCoordIndex % atlasTexSize, atlasCoordIndex / atlasTexSize); + vec4 atlasCoords = texelFetch(imageAtlasCoords, atlasTexel, 0); imageAtlasUV = atlasCoords; } #endif diff --git a/src/modules/Points/fill-picking-buffer.vert b/src/modules/Points/fill-picking-buffer.vert index 4e5128bc..d8394511 100644 --- a/src/modules/Points/fill-picking-buffer.vert +++ b/src/modules/Points/fill-picking-buffer.vert @@ -91,21 +91,21 @@ void main() { gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 1.0; - vec2 uv = (pointIndices + 0.5) / pointsTextureSize; + ivec2 pointTexel = ivec2(pointIndices); // Skip absent (faded-out) points so hover never lands on a removed one. Their // size/position may still look hittable mid-fade (only alpha faded), so the exit // status is the reliable signal. exit.G = current absence. - vec4 exitStatus = texture(exitTexture, uv); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); if (exitStatus.g > 0.5) return; - vec4 greyoutStatus = texture(pointStatus, uv); + vec4 greyoutStatus = texelFetch(pointStatus, pointTexel, 0); float isHighlighted = (greyoutStatus.r == 0.0) ? 1.0 : 0.0; if (skipHighlighted > 0.0 && isHighlighted > 0.0) return; if (skipGreyed > 0.0 && isHighlighted <= 0.0) return; - vec4 pointPosition = texture(positionsTexture, uv); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec2 point = pointPosition.rg; vec2 normalizedPosition = 2.0 * point / spaceSize - 1.0; diff --git a/src/modules/Points/fill-sampled-points.vert b/src/modules/Points/fill-sampled-points.vert index 07550629..ff7ca5b9 100644 --- a/src/modules/Points/fill-sampled-points.vert +++ b/src/modules/Points/fill-sampled-points.vert @@ -30,15 +30,17 @@ uniform mat3 transformationMatrix; out vec4 rgba; void main() { + ivec2 pointTexel = ivec2(pointIndices); + // Keep absent (faded-out) points out of the sample. exit.G = current absence. - if (texture(exitTexture, (pointIndices + 0.5) / pointsTextureSize).g > 0.5) { + if (texelFetch(exitTexture, pointTexel, 0).g > 0.5) { rgba = vec4(0.0); gl_Position = vec4(2.0, 2.0, 2.0, 1.0); gl_PointSize = 0.0; return; } - vec4 pointPosition = texture(positionsTexture, (pointIndices + 0.5) / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec2 p = 2.0 * pointPosition.rg / spaceSize - 1.0; p *= spaceSize / screenSize; #ifdef USE_UNIFORM_BUFFERS diff --git a/src/modules/Points/find-points-in-polygon.frag b/src/modules/Points/find-points-in-polygon.frag index f500cc85..69f92035 100644 --- a/src/modules/Points/find-points-in-polygon.frag +++ b/src/modules/Points/find-points-in-polygon.frag @@ -26,22 +26,19 @@ uniform vec2 screenSize; uniform mat3 transformationMatrix; #endif -in vec2 textureCoords; - out vec4 fragColor; // Get a point from the polygon path texture at a specific index vec2 getPolygonPoint(sampler2D pathTexture, int index, int pathLength) { if (index >= pathLength) return vec2(0.0); - - // Calculate texture coordinates for the index - int textureSize = int(ceil(sqrt(float(pathLength)))); - int x = index - (index / textureSize) * textureSize; - int y = index / textureSize; - - vec2 texCoord = (vec2(float(x), float(y)) + 0.5) / float(textureSize); - vec4 pathData = texture(pathTexture, texCoord); - + + // The path is written row-major into a square texture. Ask the texture how wide it + // is rather than re-deriving ceil(sqrt(pathLength)) — that duplicates the formula + // the allocation used, and a local named textureSize would shadow this builtin. + int width = textureSize(pathTexture, 0).x; + + vec4 pathData = texelFetch(pathTexture, ivec2(index % width, index / width), 0); + return pathData.xy; } @@ -52,7 +49,7 @@ bool pointInPolygon(vec2 point, sampler2D pathTexture, int pathLength) { for (int i = 0; i < 2048; i++) { if (i >= pathLength) break; - int j = int(mod(float(i + 1), float(pathLength))); + int j = (i + 1) % pathLength; vec2 pi = getPolygonPoint(pathTexture, i, pathLength); vec2 pj = getPolygonPoint(pathTexture, j, pathLength); @@ -67,13 +64,15 @@ bool pointInPolygon(vec2 point, sampler2D pathTexture, int pathLength) { } void main() { + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + // Skip absent (faded-out) points — never select a removed point. exit.G = absent. - if (texture(exitTexture, textureCoords).g > 0.5) { + if (texelFetch(exitTexture, pointTexel, 0).g > 0.5) { fragColor = vec4(0.0); return; } - vec4 pointPosition = texture(positionsTexture, textureCoords); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec2 p = 2.0 * pointPosition.rg / spaceSize - 1.0; p *= spaceSize / screenSize; #ifdef USE_UNIFORM_BUFFERS diff --git a/src/modules/Points/find-points-in-rect.frag b/src/modules/Points/find-points-in-rect.frag index 8fc29a62..5276351d 100644 --- a/src/modules/Points/find-points-in-rect.frag +++ b/src/modules/Points/find-points-in-rect.frag @@ -41,8 +41,6 @@ uniform float scalePointsOnZoom; uniform float maxPointSize; #endif -in vec2 textureCoords; - out vec4 fragColor; float pointSizeF(float size) { @@ -62,13 +60,15 @@ float pointSizeF(float size) { } void main() { + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + // Skip absent (faded-out) points — never select a removed point. exit.G = absent. - if (texture(exitTexture, textureCoords).g > 0.5) { + if (texelFetch(exitTexture, pointTexel, 0).g > 0.5) { fragColor = vec4(0.0); return; } - vec4 pointPosition = texture(positionsTexture, textureCoords); + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); vec2 p = 2.0 * pointPosition.rg / spaceSize - 1.0; p *= spaceSize / screenSize; #ifdef USE_UNIFORM_BUFFERS @@ -79,7 +79,7 @@ void main() { vec3 final = transformationMatrix * vec3(p, 1); #endif - vec4 pSize = texture(pointSize, textureCoords); + vec4 pSize = texelFetch(pointSize, pointTexel, 0); float size = pSize.r * sizeScale; float left = 2.0 * (rect0.x - 0.5 * pointSizeF(size)) / screenSize.x - 1.0; diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 906a4006..e0959133 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -382,12 +382,6 @@ export class Points extends CoreModule { }; }> | undefined - private trackPointsUniformStore: UniformStore<{ - trackPointsUniforms: { - pointsTextureSize: number; - }; - }> | undefined - /** Whether an issued async pick is still awaiting its GPU readback. */ public get hasPendingPickReadback (): boolean { return this.pickingReadback?.inFlight ?? false @@ -1129,19 +1123,6 @@ export class Points extends CoreModule { data: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), }) - // Create UniformStore for trackPoints uniforms - this.trackPointsUniformStore ||= new UniformStore(device, { - trackPointsUniforms: { - uniformTypes: { - // Order MUST match shader declaration order (std140 layout) - pointsTextureSize: 'f32', - }, - defaultUniforms: { - pointsTextureSize: store.pointsTextureSize ?? 0, - }, - }, - }) - this.trackPointsCommand ||= new Model(device, { fs: trackPositionsFrag, vs: updateVert, @@ -1157,9 +1138,6 @@ export class Points extends CoreModule { USE_UNIFORM_BUFFERS: true, }, bindings: { - // Create uniform buffer binding - // Update it later by calling uniformStore.setUniforms() - trackPointsUniforms: this.trackPointsUniformStore.getManagedUniformBuffer('trackPointsUniforms'), // All texture bindings will be set dynamically in trackPoints() method }, }) @@ -1352,9 +1330,13 @@ export class Points extends CoreModule { this.hasAnyAbsentPoint = anyAbsentNow // Common (no-NaN) case: every texel would be zero, so bind a 1×1 all-zero - // stand-in instead of a pointsTextureSize² texture. Any sample of it returns - // "present", it stays cache-resident so the per-vertex fetch in the hot shaders - // costs ~nothing, and the full-size texture is never allocated or uploaded. + // stand-in instead of a pointsTextureSize² texture. The shaders texelFetch it at + // the point's own texel, which is out of range for every point but the first. + // GLSL ES leaves an out-of-range fetch undefined, but WebGL 2 requires it to read + // back as zero and its conformance suite tests exactly that, so R and G read 0 — + // "present" — for every point either way. It stays cache-resident so the per-vertex + // fetch in the hot shaders costs ~nothing, and the full-size texture is never + // allocated or uploaded. if (!anyAbsentNow && !anyAbsentBefore) { if (this.exitTexture && !this.exitTexture.destroyed && this.exitTexture.width === 1) return if (this.exitTexture && !this.exitTexture.destroyed) { @@ -1670,17 +1652,11 @@ export class Points extends CoreModule { * `trackPointsByIndices()` self-calls after reallocating; no manual follow-up needed. */ public trackPoints (): void { - if (!this.trackedIndices?.length || !this.trackPointsCommand || !this.trackPointsUniformStore || + if (!this.trackedIndices?.length || !this.trackPointsCommand || !this.trackedPositionsFbo || this.trackedPositionsFbo.destroyed) return if (!this.currentPositionTexture || this.currentPositionTexture.destroyed) return if (!this.trackedIndicesTexture || this.trackedIndicesTexture.destroyed) return - this.trackPointsUniformStore.setUniforms({ - trackPointsUniforms: { - pointsTextureSize: this.store.pointsTextureSize ?? 0, - }, - }) - // Update texture bindings dynamically this.trackPointsCommand.setBindings({ positionsTexture: this.currentPositionTexture, @@ -2289,23 +2265,24 @@ export class Points extends CoreModule { } public trackPointsByIndices (indices?: number[] | undefined): void { - const { store: { pointsTextureSize }, device } = this + const { device } = this this.trackedIndices = indices // Clear cache when changing tracked indices this.trackedPositions = undefined this.isPositionsUpToDate = false - if (!indices?.length || !pointsTextureSize) return + if (!indices?.length) return const textureSize = Math.ceil(Math.sqrt(indices.length)) + // The table stores raw indices; the shader derives each texel from the + // positions texture's live width, so a point-count relayout cannot strand + // the table on the old layout. float32 carries integers exactly to 2^24 — + // the same ceiling every float-carried index in the engine lives under. const initialState = new Float32Array(textureSize * textureSize * 4).fill(-1) for (const [i, sortedIndex] of indices.entries()) { if (sortedIndex !== undefined) { - initialState[i * 4] = sortedIndex % pointsTextureSize - initialState[i * 4 + 1] = Math.floor(sortedIndex / pointsTextureSize) - initialState[i * 4 + 2] = 0 - initialState[i * 4 + 3] = 0 + initialState[i * 4] = sortedIndex } } @@ -2371,6 +2348,10 @@ export class Points extends CoreModule { if (!this.trackedPositionsFbo || this.trackedPositionsFbo.destroyed) return new Map() + // Frames gather after position changes, but a read can come first + // (static graph, or tracking set before data) — gather here when stale. + if (!this.isPositionsUpToDate) this.trackPoints() + const pixels = readPixels(this.device, this.trackedPositionsFbo as Framebuffer) const tracked = new Map() @@ -2381,7 +2362,10 @@ export class Points extends CoreModule { if (x !== undefined && y !== undefined && index !== undefined) { // Omit absent (removed) points — the tracked FBO holds their frozen last // coordinate, which must not be reported as a live position. A missing key - // is the map's way of saying "this point is gone". + // is the map's way of saying "this point is gone". An index with no point + // behind it under the current count is omitted the same way, and comes + // back if the count grows to include it. + if (!this.data.isPointIndex(index)) continue if (this.data.pointPositions && isPointAbsent(this.data.pointPositions, index)) continue tracked.set(index, [x, y]) } @@ -2503,6 +2487,8 @@ export class Points extends CoreModule { if (!this.trackedIndices) return positions if (!this.trackedPositionsFbo || this.trackedPositionsFbo.destroyed) return positions positions.length = this.trackedIndices.length * 2 + // Same as the map readback: gather when stale. + if (!this.isPositionsUpToDate) this.trackPoints() const pixels = readPixels(this.device, this.trackedPositionsFbo as Framebuffer) for (let i = 0; i < pixels.length / 4; i += 1) { const x = pixels[i * 4] @@ -2511,8 +2497,10 @@ export class Points extends CoreModule { if (x !== undefined && y !== undefined && index !== undefined) { // An absent (removed) point reads back as NaN. Unlike the map (which omits // it), the array must keep the slot so positions stay aligned with the - // tracked indices. - if (this.data.pointPositions && isPointAbsent(this.data.pointPositions, index)) { + // tracked indices. An index with no point behind it under the current + // count gets the same NaN slot. + if (!this.data.isPointIndex(index) || + (this.data.pointPositions && isPointAbsent(this.data.pointPositions, index))) { positions[i * 2] = NaN positions[i * 2 + 1] = NaN continue @@ -2681,8 +2669,6 @@ export class Points extends CoreModule { this.fillSampledPointsUniformStore = undefined this.drawHighlightedUniformStore?.destroy() this.drawHighlightedUniformStore = undefined - this.trackPointsUniformStore?.destroy() - this.trackPointsUniformStore = undefined // 5. Destroy Buffers (passed via attributes - NOT owned by Models, must destroy manually) if (this.sourceColorBuffer && !this.sourceColorBuffer.destroyed) { diff --git a/src/modules/Points/interpolate-position.frag b/src/modules/Points/interpolate-position.frag index ad9f0475..03a0fc53 100644 --- a/src/modules/Points/interpolate-position.frag +++ b/src/modules/Points/interpolate-position.frag @@ -16,13 +16,13 @@ layout(std140) uniform interpolatePositionUniforms { uniform float progress; #endif -in vec2 textureCoords; - out vec4 fragColor; void main() { - vec4 source = texture(sourceTexture, textureCoords); - vec4 target = texture(targetTexture, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 source = texelFetch(sourceTexture, pointTexel, 0); + vec4 target = texelFetch(targetTexture, pointTexel, 0); // NaN means absent (ingest normalizes half-NaN to full-NaN, so checking one // channel suffices). Hold the real side so the point stays put while it fades, // never interpolating to/from NaN: diff --git a/src/modules/Points/track-positions.frag b/src/modules/Points/track-positions.frag index 1207c5f5..e25cb4e1 100644 --- a/src/modules/Points/track-positions.frag +++ b/src/modules/Points/track-positions.frag @@ -1,30 +1,32 @@ #version 300 es #ifdef GL_ES precision highp float; +// Fragment shaders default int to mediump, guaranteed only to 32767 — +// point indices go far higher. +precision highp int; #endif uniform sampler2D positionsTexture; uniform sampler2D trackedIndices; -#ifdef USE_UNIFORM_BUFFERS -layout(std140) uniform trackPointsUniforms { - float pointsTextureSize; -} trackPoints; - -#define pointsTextureSize trackPoints.pointsTextureSize -#else -uniform float pointsTextureSize; -#endif - -in vec2 textureCoords; - out vec4 fragColor; void main() { - vec4 trackedPointIndices = texture(trackedIndices, textureCoords); - if (trackedPointIndices.r < 0.0) discard; - vec4 pointPosition = texture(positionsTexture, (trackedPointIndices.rg + 0.5) / pointsTextureSize); + ivec2 trackedTexel = ivec2(gl_FragCoord.xy); + + // The table holds raw point indices (-1 = unused slot). The texel is derived + // here, from the width the positions texture has right now — the grid relayouts + // when the point count changes, so a texel computed at bake time would keep + // addressing the old layout. textureSize() is valid here because this shader + // samples positionsTexture itself. + float index = texelFetch(trackedIndices, trackedTexel, 0).r; + if (index < 0.0) discard; + + int i = int(index); + int w = textureSize(positionsTexture, 0).x; + // An index past the point count fetches out of range and yields zeros; the + // CPU readback owns range validation and drops such entries. + vec4 pointPosition = texelFetch(positionsTexture, ivec2(i % w, i / w), 0); fragColor = vec4(pointPosition.rg, 1.0, 1.0); } - diff --git a/src/modules/Points/update-position.frag b/src/modules/Points/update-position.frag index 994b9c61..cf502d72 100644 --- a/src/modules/Points/update-position.frag +++ b/src/modules/Points/update-position.frag @@ -21,18 +21,18 @@ uniform float friction; uniform float spaceSize; #endif -in vec2 textureCoords; - out vec4 fragColor; void main() { - vec4 pointPosition = texture(positionsTexture, textureCoords); - vec4 pointVelocity = texture(velocity, textureCoords); + ivec2 pointTexel = ivec2(gl_FragCoord.xy); + + vec4 pointPosition = texelFetch(positionsTexture, pointTexel, 0); + vec4 pointVelocity = texelFetch(velocity, pointTexel, 0); // Check if point is pinned // pinnedStatusTexture has the same size and layout as positionsTexture // Each pixel corresponds to a point: red channel > 0.5 means the point is pinned - vec4 pinnedStatus = texture(pinnedStatusTexture, textureCoords); + vec4 pinnedStatus = texelFetch(pinnedStatusTexture, pointTexel, 0); // If pinned, don't update position if (pinnedStatus.r > 0.5) { @@ -42,7 +42,7 @@ void main() { // If absent (current absence = exit.G), leave it untouched — don't integrate or // clamp it (clamping NaN is undefined and could resurrect the point at (0,0)). - vec4 exitStatus = texture(exitTexture, textureCoords); + vec4 exitStatus = texelFetch(exitTexture, pointTexel, 0); if (exitStatus.g > 0.5) { fragColor = pointPosition; return; diff --git a/src/modules/Shared/buffer.ts b/src/modules/Shared/buffer.ts index 3ab961e0..f132f3e0 100644 --- a/src/modules/Shared/buffer.ts +++ b/src/modules/Shared/buffer.ts @@ -1,5 +1,16 @@ import { Buffer, Device } from '@luma.gl/core' +/** + * One (x, y) texel coordinate per texel of a `textureSize`² data texture, in row-major + * order, so a draw of N vertices walks texels 0…N-1. + * + * The values are whole numbers exactly representable in float32, which is what lets the + * shaders read them back as `texelFetch(tex, ivec2(pointIndices), 0)`: `ivec2` truncates, + * and truncating an exact integer is exact. Shaders must not sample these textures with + * normalized coordinates instead — `index / textureSize` lands on a texel *boundary*, + * where the sampler's floor can fall to the previous texel and silently return another + * point's data. + */ export function createIndexesForBuffer (textureSize: number): Float32Array { const indexes = new Float32Array(textureSize * textureSize * 2) for (let y = 0; y < textureSize; y++) { diff --git a/src/modules/Shared/quad.vert b/src/modules/Shared/quad.vert index ae803813..92f4f1bc 100644 --- a/src/modules/Shared/quad.vert +++ b/src/modules/Shared/quad.vert @@ -4,10 +4,7 @@ precision highp float; #endif in vec2 vertexCoord; // Vertex coordinates in normalized device coordinates -out vec2 textureCoords; // Texture coordinates to pass to the fragment shader void main() { - // Convert vertex coordinates from [-1, 1] range to [0, 1] range for texture sampling - textureCoords = (vertexCoord + 1.0) / 2.0; gl_Position = vec4(vertexCoord, 0, 1); } diff --git a/src/modules/Store/index.ts b/src/modules/Store/index.ts index 2f687321..a53acd4e 100644 --- a/src/modules/Store/index.ts +++ b/src/modules/Store/index.ts @@ -180,9 +180,15 @@ export class Store { public set backgroundColor (color: [number, number, number, number]) { this._backgroundColor = color const brightness = rgbToBrightness(color[0], color[1], color[2]) - document.documentElement.style.setProperty('--cosmosgl-attribution-color', brightness > 0.65 ? 'black' : 'white') - document.documentElement.style.setProperty('--cosmosgl-error-message-color', brightness > 0.65 ? 'black' : 'white') - if (this.div) this.div.style.backgroundColor = `rgba(${color[0] * 255}, ${color[1] * 255}, ${color[2] * 255}, ${color[3]})` + if (this.div) { + // Set on the graph's container, not document.documentElement — the + // attribution and error elements inherit from it, and multiple Graph + // instances with different backgrounds must not fight over one global value. + const contrastColor = brightness > 0.65 ? 'black' : 'white' + this.div.style.setProperty('--cosmosgl-attribution-color', contrastColor) + this.div.style.setProperty('--cosmosgl-error-message-color', contrastColor) + this.div.style.backgroundColor = `rgba(${color[0] * 255}, ${color[1] * 255}, ${color[2] * 255}, ${color[3]})` + } this.isDarkenGreyout = brightness < 0.65 } diff --git a/src/modules/Zoom/index.ts b/src/modules/Zoom/index.ts index 77e90ff1..bbc78f73 100644 --- a/src/modules/Zoom/index.ts +++ b/src/modules/Zoom/index.ts @@ -10,6 +10,21 @@ export class Zoom { public eventTransform = zoomIdentity public behavior = zoom() .scaleExtent([0.001, Infinity]) + .filter((event: MouseEvent | WheelEvent | TouchEvent): boolean => { + // With zooming disabled only `wheel.zoom` used to be detached, leaving + // double-click and pinch zoom active. Panning must keep working, so + // block just the scale-changing gestures here instead. + if (!this.config.enableZoom) { + // `touchend` blocks double-tap: d3-zoom reroutes the second tap's + // touchend into its dblclick handler, which re-applies this filter. + // Pan gestures never reach the filter with a touchend, so this only + // stops the double-tap zoom. + if (event.type === 'wheel' || event.type === 'dblclick' || event.type === 'touchend') return false + if ('touches' in event && event.touches.length > 1) return false + } + // Mirrors d3-zoom's default filter. + return (!event.ctrlKey || event.type === 'wheel') && ((event as MouseEvent).button ?? 0) === 0 + }) .on('start', (e: D3ZoomEvent) => { this.isRunning = true // User-driven zooms (scroll, pinch) clear any programmatic override diff --git a/src/stories/api-reference.mdx b/src/stories/api-reference.mdx index 5fba9a92..992f3752 100644 --- a/src/stories/api-reference.mdx +++ b/src/stories/api-reference.mdx @@ -433,7 +433,7 @@ Sets which points are pinned (fixed) in position. Pinned points do not move due ### # graph.render([simulationAlpha], [transitionDuration]) -The `render` method renders the graph and starts rendering. It does NOT modify simulation state - use `start()`, `stop()`, `pause()`, or `unpause()` to control the simulation. +The `render` method applies pending data changes and renders the graph. It does not start or stop the simulation — use `start()`, `stop()`, `pause()`, or `unpause()` for that. Two exceptions: the `simulationAlpha` argument sets the alpha when provided, and a position transition started with a positive `transitionDuration` (or its `config.transitionDuration` fallback) pauses a running simulation (see `setPointPositions`). * **`simulationAlpha`** (number, optional): Optional alpha value to set. - If `0`: Sets alpha to 0, simulation stops after one frame (graph becomes static). @@ -667,7 +667,7 @@ The sample distributes links evenly across the visible area. ### # graph.start([alpha]) -Starts the simulation. This method only controls the simulation state, not rendering. Rendering is started automatically by `render()`. +Starts the simulation. Data ingest and the initial render belong to `render()`; this method sets the simulation running and requests frames to drive it. An active position transition is ended immediately (`onTransitionEnd` fires with `interrupted: true`). * **`alpha`** (Number, optional): A number between `0` and `1` representing the initial energy of the simulation. The default value is `1` if not provided. A higher `alpha` value results in more initial energy for the simulation. @@ -681,7 +681,7 @@ Pauses the simulation. When paused, the simulation stops running but preserves i ### # graph.unpause() -Unpauses (resumes) the simulation. This method resumes a paused simulation and continues its execution from where it was paused. +Unpauses (resumes) the simulation. This method resumes a paused simulation and continues its execution from where it was paused. An active position transition is ended immediately (`onTransitionEnd` fires with `interrupted: true`). If a **position** transition is currently active when `unpause()` is called, the transition is ended as interrupted (`onTransitionEnd(true)` fires) and the simulation resumes from the current mid-animation positions. @@ -701,7 +701,7 @@ Destroys the current cosmos.gl instance and cleans up all resources. ### # graph.create() -Applies pending data changes (positions, colors, sizes, shapes, links, forces, clusters) to the graph visualization. Call this after setting data via methods like `setPointPositions`, `setPointColors`, `setLinks`, etc. if you need to apply changes without calling `render()`. +Uploads the processed data to the GPU for every channel whose update flag is set (positions, colors, sizes, shapes, images, links, forces, clusters) and requests a redraw. An internal stage of the render pipeline: it does not ingest new input arrays, so data passed to `setPointPositions`, `setPointColors`, `setLinks`, etc. takes effect only on the next `render()`. To apply new data while keeping the current alpha, call `render(undefined)` — or `render(undefined, 0)` to also snap instead of animating. ### # graph.flatten(pointPositions) diff --git a/src/stories/configuration.mdx b/src/stories/configuration.mdx index 7034e865..a15ddf8a 100644 --- a/src/stories/configuration.mdx +++ b/src/stories/configuration.mdx @@ -51,7 +51,7 @@ All configuration properties are optional. When creating a graph or calling `set | focusedLinkIndex | Set focus on a link by index. The focused link will be rendered wider. When set to `undefined`, no link is focused. | `undefined` | | focusedLinkWidthIncrease | Number of pixels to add to the link width when focused | `5` | | scaleLinksOnZoom | Increase/decrease link width when zooming | `false` | -| linkBlending | Controls alpha blending for link rendering. Disable for faster link rendering when transparency and antialiased link edges are not needed. | `true` | +| linkBlending | Controls alpha blending for link rendering. `true`: source-over blending for transparency and soft AA edges. `false`: opaque hard edges (faster for dense graphs). | `true` | | curvedLinks | If set to true, links are rendered as curved lines. Otherwise as straight lines | `false` | | curvedLinkSegments | Number of segments in a curved line | `19` | | curvedLinkWeight | Weight affects the shape of the curve | `0.8` |