From e46daeb158a778d6f3aca0db2a7e50aa51404773 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Jul 2026 17:17:58 +0500 Subject: [PATCH 01/19] fix: stop multiple Graph instances from clobbering each other Three shared-global collisions broke pages hosting more than one Graph: - The space-key handlers were registered on document under the fixed d3 namespace .cosmos, so a second instance silently replaced the first instance's handlers, and either instance's destroy() removed the survivor's. Each instance now namespaces its document listeners with a random id (.cosmos-). - The --cosmosgl-attribution-color / --cosmosgl-error-message-color CSS variables were written to document.documentElement, making instances with different backgrounds fight over one global value. They are now set on the graph's container div, which the attribution and error elements inherit from. - The FPS monitor widget and its injected style lived on document.body under the global ids #gl-bench / #gl-bench-style; constructing a second monitor removed the first instance's widget, and Graph's destroy() reached into gl-bench internals. The widget is now mounted inside the graph container and cleaned up by FPSMonitor.destroy(), which also fixes the style element leaking on every showFPSMonitor toggle. Any number of Graph instances can now coexist on one page - and be destroyed in any order - without affecting each other's key handling, attribution contrast, or FPS monitor. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/helper.ts | 11 +++++++++++ src/index.ts | 20 ++++++++++++-------- src/modules/FPSMonitor/index.ts | 12 ++++++++---- src/modules/Store/index.ts | 12 +++++++++--- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/helper.ts b/src/helper.ts index 61e8d134..5442de0d 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -88,6 +88,17 @@ 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) + return Array.from(words, (word) => word.toString(36)).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. diff --git a/src/index.ts b/src/index.ts index d3086e70..c90e503e 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) @@ -1543,7 +1549,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,8 +1606,6 @@ export class Graph { this.attributionDivElement.parentNode.removeChild(this.attributionDivElement) } - document.getElementById('gl-bench-style')?.remove() - this.canvasD3Selection = undefined this.attributionDivElement = undefined } @@ -1824,7 +1828,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 diff --git a/src/modules/FPSMonitor/index.ts b/src/modules/FPSMonitor/index.ts index 8764e2af..359b0759 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,7 @@ export class FPSMonitor { public destroy (): void { this.bench = undefined - select('#gl-bench').remove() + this.container.querySelector('#gl-bench')?.remove() + this.container.querySelector('#gl-bench-style')?.remove() } } 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 } From f7b5113537e2e1513895d01397ebf118c546e1c1 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Jul 2026 18:14:15 +0500 Subject: [PATCH 02/19] fix: scope FPS monitor cleanup to direct children of its container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoping the gl-bench cleanup to the graph's container (ca21698) still used descendant queries, so a monitor whose container encloses another graph's container — e.g. one graph on document.body and another mounted inside it — could find and remove the nested monitor's widget and style on construct or destroy. - Use ':scope >' selectors: gl-bench appends both #gl-bench and #gl-bench-style as direct children of the dom it is given, so the direct-child query always reaches the monitor's own elements and never a nested instance's. A monitor now removes only elements it created, regardless of how graph containers nest. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/modules/FPSMonitor/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/modules/FPSMonitor/index.ts b/src/modules/FPSMonitor/index.ts index 359b0759..d20ad6d3 100644 --- a/src/modules/FPSMonitor/index.ts +++ b/src/modules/FPSMonitor/index.ts @@ -26,7 +26,9 @@ export class FPSMonitor { public destroy (): void { this.bench = undefined - this.container.querySelector('#gl-bench')?.remove() - this.container.querySelector('#gl-bench-style')?.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() } } From e8b0abed458f1e8783321d34ea06f280af5adb6b Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Jul 2026 18:44:26 +0500 Subject: [PATCH 03/19] fix(config): apply runtime config changes that were silently ignored Three config properties accepted new values via setConfig without any effect: - pointSamplingDistance / linkSamplingDistance were only read when the sampling grids were rebuilt on canvas resize or data recreation, so a runtime change did nothing until the window resized. The config diff now rebuilds the grids; the rebuild is idempotent and skips when the grid dimensions are unchanged. - pointDefaultSize left point image sizes stale forever: they default to a copy of point sizes, but the config branch only refreshed the size channel. It now refreshes image sizes too; explicit user-set image sizes are preserved since only missing/NaN entries resolve through the default. - enableZoom: false only detached wheel.zoom, leaving double-click and pinch zoom active. A d3-zoom filter now blocks the scale-changing gestures (wheel, dblclick, multi-touch) while keeping panning and programmatic zoom alive. setConfig now guarantees these keys take effect immediately, matching every other config property. Co-authored-by: Nikita Rokotyan Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/index.ts | 11 +++++++++++ src/modules/Zoom/index.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/index.ts b/src/index.ts index c90e503e..523c2956 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1730,6 +1730,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() @@ -1837,6 +1840,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/Zoom/index.ts b/src/modules/Zoom/index.ts index 77e90ff1..32b26371 100644 --- a/src/modules/Zoom/index.ts +++ b/src/modules/Zoom/index.ts @@ -10,6 +10,17 @@ 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) { + if (event.type === 'wheel' || event.type === 'dblclick') 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 From c440ce4d48fd95f08bdade477512d557706d3895 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Thu, 30 Jul 2026 16:26:50 +0500 Subject: [PATCH 04/19] fix(shaders): read data textures by texel index, never by coordinate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten fetches in the force shaders addressed their data textures at the texel CORNER, `index / textureSize`. That coordinate lands exactly on the boundary between texel index-1 and index, and NEAREST selection is a floor of the size-scaled coordinate, so it returns the intended texel only while the driver's arithmetic does not fall even one ULP short. It falls short 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 a size that silently reads the wrong point. Which sizes fail is a driver property, not an arithmetic one: SwiftShader's failing set is nearly disjoint from Metal's, so no texture size is portably safe. The engine's own writes never had this 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. The contract is now uniform: a data texture is an array, so it is addressed by index. `texelFetch` takes the integer texel directly, does no coordinate arithmetic, and ignores filter and wrap state, which removes the defect instead of hiding it behind a margin. `texture()` survives only where the coordinate is genuinely continuous — the image atlas, the one place filtering is the point. - Clusters/force-cluster.frag is where users saw it. The cluster force's entire target is one fetch shared by every member of a cluster, so a misfetch relocates a whole cluster onto its neighbour. With 1089 pinned clusters (clustersTextureSize 33) only 7.1% of points reached their own cluster; they now all do, and a control at the exact size 23 is unchanged in both builds. - The exit-status reads convert together with the position reads beside them. They shared one coordinate expression, so they erred together and the shader coherently processed the wrong point; converting only the positions would let point k's NaN position past point k-1's absence guard and poison the centroid and collision sums. - The reads that already used the `(index + 0.5) / size` centre form convert too. They were correct, but only because half a texel of margin absorbed the same driver error — one rule is worth more than a second form that has to be re-justified at every new call site. - Full-screen passes take their texel from `ivec2(gl_FragCoord.xy)` instead of an interpolated quad varying, so each fragment addresses its own element exactly rather than by a rasterised coordinate. Every such pass renders into a target whose dimensions equal the textures it samples. quad.vert's varying had no consumer left and is gone. - ForceLink/force-spring.ts is included. Its shader is built from a template literal, so it is invisible to a sweep filtered to .vert/.frag files — sweep for `#version 300 es` instead. - find-points-in-polygon.frag asks `textureSize()` for its path texture's width rather than re-deriving ceil(sqrt(pathLength)), which duplicated the formula the allocation used and shadowed that builtin. - draw-highlighted.vert guards its index before the integer `%` and `/`. `pointIndex` defaults to -1, and both operators are undefined on a negative or zero operand where the old float `mod()` was not. - Nine uniform-block members lost their last reader and are removed, taking two whole UniformStores with them. A member spans the std140 block, its `#define`, the non-UBO declaration and three TypeScript sites, and nothing checks that they agree, so each was removed in lockstep and every block's order re-verified against its uniformTypes. Two consequences worth knowing. The 1×1 all-zero exit texture bound when no point is absent now relies on WebGL 2 defining an out-of-range texelFetch as zero, where it previously relied on CLAMP_TO_EDGE; the optimisation is documented at the allocation. And trackPointPositions never re-bakes its texel pairs when the point count changes, so a stale tracked index now reports (0, 0) instead of another point's position — pre-existing, but the symptom changed and it wants its own fix. A data-texture read can no longer resolve to the wrong element. Behaviour is otherwise unchanged: everything that was already correct produces identical positions, identical rect, polygon and sampling results, and a byte-identical frame. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- AGENTS.md | 7 ++++ .../Clusters/calculate-centermass.vert | 11 +++-- src/modules/Clusters/force-cluster.frag | 19 ++++----- src/modules/Clusters/index.ts | 8 ---- .../ForceCenter/calculate-centermass.vert | 16 ++----- src/modules/ForceCenter/force-center.frag | 7 ++-- src/modules/ForceCenter/index.ts | 26 +----------- src/modules/ForceCollision/build-grid.vert | 11 +++-- .../force-collision-spatial.frag | 16 ++++--- src/modules/ForceCollision/index.ts | 6 --- src/modules/ForceGravity/force-gravity.frag | 5 ++- src/modules/ForceLink/force-spring.ts | 25 +++++------ src/modules/ForceLink/index.ts | 3 -- .../ForceManyBody/build-nearfield-slots.vert | 6 ++- .../ForceManyBody/calculate-level.vert | 9 ++-- src/modules/ForceManyBody/force-level.frag | 5 ++- .../ForceManyBody/force-nearfield.frag | 7 ++-- src/modules/ForceManyBody/index.ts | 4 -- src/modules/ForceMouse/force-mouse.frag | 5 ++- src/modules/Lines/draw-curve-line.vert | 27 ++++++------ src/modules/Lines/fill-sampled-links.vert | 14 +++---- src/modules/Lines/index.ts | 10 ----- src/modules/Points/drag-point.frag | 6 +-- src/modules/Points/draw-highlighted.vert | 16 +++++-- src/modules/Points/draw-points.vert | 26 ++++++------ src/modules/Points/fill-picking-buffer.vert | 8 ++-- src/modules/Points/fill-sampled-points.vert | 6 ++- .../Points/find-points-in-polygon.frag | 25 ++++++----- src/modules/Points/find-points-in-rect.frag | 10 ++--- src/modules/Points/index.ts | 42 ++++--------------- src/modules/Points/interpolate-position.frag | 8 ++-- src/modules/Points/track-positions.frag | 18 ++------ src/modules/Points/update-position.frag | 12 +++--- src/modules/Shared/buffer.ts | 11 +++++ src/modules/Shared/quad.vert | 3 -- 35 files changed, 181 insertions(+), 257 deletions(-) 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/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/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..827dca67 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,9 +84,9 @@ 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 diff --git a/src/modules/ForceCollision/index.ts b/src/modules/ForceCollision/index.ts index d5fa4330..9d9253da 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; @@ -168,7 +166,6 @@ export class ForceCollision extends CoreModule { this.buildGridUniformStore ||= new UniformStore(device, { buildGridUniforms: { uniformTypes: { - pointsTextureSize: 'f32', gridTextureSize: 'f32', cellSize: 'f32', gridOffset: 'vec2', @@ -211,7 +208,6 @@ export class ForceCollision extends CoreModule { this.forceUniformStore ||= new UniformStore(device, { forceCollisionUniforms: { uniformTypes: { - pointsTextureSize: 'f32', gridTextureSize: 'f32', cellSize: 'f32', alpha: 'f32', @@ -292,7 +288,6 @@ export class ForceCollision extends CoreModule { this.buildGridUniformStore.setUniforms({ buildGridUniforms: { - pointsTextureSize: store.pointsTextureSize ?? 0, gridTextureSize: this.gridTextureSize, cellSize: this.cellSize, gridOffset, @@ -325,7 +320,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..cddbbd53 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; }; @@ -182,7 +181,6 @@ export class ForceLink extends CoreModule { linkSpring: 'f32', linkDistance: 'f32', linkDistRandomVariationRange: 'vec2', - pointsTextureSize: 'f32', linksTextureSize: 'f32', alpha: 'f32', }, @@ -238,7 +236,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..3132ed26 100644 --- a/src/modules/ForceManyBody/force-nearfield.frag +++ b/src/modules/ForceManyBody/force-nearfield.frag @@ -55,7 +55,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 +96,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/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..24491318 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; @@ -173,7 +171,6 @@ export class Lines extends CoreModule { drawLineUniforms: { uniformTypes: { transformationMatrix: 'mat4x4', - pointsTextureSize: 'f32', widthScale: 'f32', linkArrowsSizeScale: 'f32', spaceSize: 'f32', @@ -203,7 +200,6 @@ export class Lines extends CoreModule { }, defaultUniforms: { transformationMatrix: store.transformationMatrix4x4, - pointsTextureSize: store.pointsTextureSize, widthScale: config.linkWidthScale, linkArrowsSizeScale: config.linkArrowsSizeScale, spaceSize: store.adjustedSpaceSize, @@ -260,7 +256,6 @@ export class Lines extends CoreModule { this.fillSampledLinksUniformStore ||= new UniformStore(device, { fillSampledLinksUniforms: { uniformTypes: { - pointsTextureSize: 'f32', transformationMatrix: 'mat4x4', spaceSize: 'f32', screenSize: 'vec2', @@ -269,7 +264,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 +329,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, @@ -751,7 +744,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 +792,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 +860,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, 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..0b9ae6c2 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; } @@ -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..7123b77e 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, @@ -2681,8 +2657,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..0d7594c1 100644 --- a/src/modules/Points/track-positions.frag +++ b/src/modules/Points/track-positions.frag @@ -6,24 +6,14 @@ precision highp float; 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); + ivec2 trackedTexel = ivec2(gl_FragCoord.xy); + + vec4 trackedPointIndices = texelFetch(trackedIndices, trackedTexel, 0); if (trackedPointIndices.r < 0.0) discard; - vec4 pointPosition = texture(positionsTexture, (trackedPointIndices.rg + 0.5) / pointsTextureSize); + vec4 pointPosition = texelFetch(positionsTexture, ivec2(trackedPointIndices.rg), 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); } From c638cb1b565108c02519dbf69f9b92566248e665 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Thu, 30 Jul 2026 16:40:11 +0500 Subject: [PATCH 05/19] docs(history): record why data-texture reads moved to texelFetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the reasoning behind `fix(shaders): read data textures by texel index, never by coordinate` (f6c6a97): the measurements that showed corner addressing failing on real hardware, and the two alternatives that were weighed and rejected — adding the `(index + 0.5)` half-texel margin, which only hides the fragility and has to be re-argued at every new call site, and switching the size uniforms to `textureSize()`, which cannot answer for a render target and misreports a bound placeholder. Also records what the change quietly moved: the 1×1 exit-texture stand-in now rests on WebGL defining an out-of-range fetch as zero rather than on CLAMP_TO_EDGE, which supersedes the justification given in the NaN point-removal entry, and the tracked-index staleness whose symptom shifted without its cause being fixed. The verification table states the evidence and, next to it, that all of it comes from a single GPU. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- .../2026-07-30-data-texture-addressing.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 history/2026/2026-07-30-data-texture-addressing.md 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..c24aa86a --- /dev/null +++ b/history/2026/2026-07-30-data-texture-addressing.md @@ -0,0 +1,128 @@ + + +# Addressing data textures by texel index + +**Commits:** `fix(shaders): read data textures by texel index, never by coordinate` +(`f6c6a97`) + +## 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. + +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.** `trackPointPositionsByIndices` bakes its texel pairs + from the `pointsTextureSize` current at call time and never re-bakes them when + the point count changes. A stale index used to clamp and report *some* real + point's position; it now reads out of range and reports `(0, 0)`, so a tracked + set lands at the origin and reads like a layout bug. Pre-existing — the symptom + changed, and the re-bake is still owed. + +## 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. +- `npm run build` exits 0 even when TypeScript errors are printed, so a green + build is not a type check — `npx tsc --noEmit` is. From 80505e96936b30ac08c2afef011469f116d45fae Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 4 Aug 2026 15:36:59 +0500 Subject: [PATCH 06/19] fix(zoom): block double-tap zoom when enableZoom is false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The disabled-zoom filter rejected wheel, dblclick, and multi-touch, but a single-finger double-tap still zoomed: d3-zoom implements double-tap by rerouting the second tap's touchend into its dblclick handler, which re-applies the filter with that touchend event — a type the filter let through, so the ×2 scale transition ran anyway. - Reject touchend in the disabled branch. d3-zoom consults the filter only from its wheel, mousedown, dblclick, and touchstart handlers, so a touchend reaches it solely via the double-tap reroute — rejecting it cannot affect one-finger panning. - Verified in headless Chromium with touch against real Graph instances: the pre-fix bundle zooms 1 → 2 on double-tap despite enableZoom: false; the fixed bundle holds 1, double-tap still zooms when enabled, and one-finger panning keeps working while disabled. Disabling zoom now blocks every scale-changing gesture — wheel, double-click, pinch, and double-tap — while panning and programmatic zoom stay live. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/modules/Zoom/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/Zoom/index.ts b/src/modules/Zoom/index.ts index 32b26371..bbc78f73 100644 --- a/src/modules/Zoom/index.ts +++ b/src/modules/Zoom/index.ts @@ -15,7 +15,11 @@ export class Zoom { // double-click and pinch zoom active. Panning must keep working, so // block just the scale-changing gestures here instead. if (!this.config.enableZoom) { - if (event.type === 'wheel' || event.type === 'dblclick') return false + // `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. From a8f8bd84fe3e9ab569ba572620f32cb3e69dcaa0 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 4 Aug 2026 18:16:42 +0500 Subject: [PATCH 07/19] fix(data): resolve invalid link strengths and odd position arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two caller-supplied array shapes could break the layout, both of them values a user can produce without doing anything obviously wrong. - A NaN or negative value in setLinkStrength slipped past the `??` fallback in ForceLink, so Math.sqrt wrote NaN into the strength texture. The poisoned velocity reached update-position.frag, where clamp() turns NaN into 0 — both endpoints of that link snapped to (0, 0) and stuck there while the rest of the graph laid out normally. Invalid values now resolve to the degree-based default — the same fallback a missing value already used. - An odd-length setPointPositions array left a dangling x with no y, so pointsNumber came out fractional and new Array(pointsNumber) in the adjacency and degree builds threw RangeError: Invalid array length. The trailing value is now dropped with a warning before anything derives a count from it; subarray() is a view, so the caller's array is never edited. Verified on ANGLE/Apple M3: a NaN or negative strength on one link previously pinned its endpoints to (0, 0) and now lets them settle with the rest; an odd-length array previously threw and now yields the even prefix. Link rendering is unchanged. An invalid value in an input array now degrades to the documented default instead of corrupting positions the user never fed in. Co-authored-by: Nikita Rokotyan Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/modules/ForceLink/index.ts | 8 ++++++-- src/modules/GraphData/index.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/modules/ForceLink/index.ts b/src/modules/ForceLink/index.ts index cddbbd53..6c930836 100644 --- a/src/modules/ForceLink/index.ts +++ b/src/modules/ForceLink/index.ts @@ -64,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 diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index 14fa7040..17a02deb 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 From 4f89a86607194e1700bd3fab950a36cae16fa0bf Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 4 Aug 2026 19:15:26 +0500 Subject: [PATCH 08/19] fix(points): exclude padding texels from search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The position texture is square, so any point count that is not a perfect square leaves trailing texels that hold position (0, 0) — a real location, the corner of the space. The search shaders run over every texel and cannot tell padding from a point, so each padding texel reports itself as found whenever the search area covers the screen position of the space origin. findPointsInRect and findPointsInPolygon then returned indices >= pointsNumber: points the caller never added. - extractIndicesFromPixels stops at the real point count. A texel's linear index is the point index (x = i % size, y = i / size), so padding is always the trailing run and truncating there cannot drop a real point. - The bound is an optional parameter. The helper is re-exported from the package entry, so external callers keep the current behaviour. Reachable without doing anything unusual: with 5 points, zooming out until the space corner is on screen and dragging a selection across the canvas returned 9 indices, 4 of which did not exist; it now returns 5, and a search over the origin returns none instead of 4. Perfect-square point counts have no padding and never failed, which made this look intermittent. A search result now holds only indices the caller can look up. Co-authored-by: Nikita Rokotyan Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/helper.ts | 12 +++++++++--- src/index.ts | 4 ++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/helper.ts b/src/helper.ts index 5442de0d..3e8fe752 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -102,11 +102,17 @@ export function generateRandomId (): string { /** * 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 523c2956..7ab63518 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1226,7 +1226,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) } /** @@ -1253,7 +1253,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) } /** From 5ff6b3bd7ddc3357c7d7d0b06075732a8dfe31d1 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 4 Aug 2026 22:51:57 +0500 Subject: [PATCH 09/19] fix(force): cover the full collision range and unbias cell averages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collision silently failed to separate points at ordinary sizes. The grid cell measured one effective radius, but two touching points interact at two radii apart and the 3x3 neighbourhood scan reaches only one cell of separation — so a colliding pair could sit in cells the scan never compares. The 8-unit cell floor hid it: points at the default size of 4 were covered, anything larger was not. - The cell now spans the full interaction range, 2 x effectiveRadius. The four offset passes reshuffle cell alignment to catch boundary cases; they never extended the search radius, so no number of passes could have closed this gap. - A cell's accumulated position and size included the point itself while the force count excluded it, dragging the average toward the point and reporting a short distance — halved for a two-point cell, overstating the overlap. The self-contribution is subtracted before averaging, and a cell holding only this point is skipped. Measured on settled simulations with collision as the only active force, counting pairs left closer than their touching distance: 200 points, size 30 79 overlapping pairs, worst 24% of a diameter interpenetrating -> 0 40 points, size 100 18 pairs, worst 18% -> 0 300 points, size 8 0 -> 0; the small-point case the cell floor already covered is not degraded by wider cells Collision resolves overlaps at any point size now, not only where the cell floor happened to span the interaction range. Co-authored-by: Nikita Rokotyan Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- .../force-collision-spatial.frag | 18 ++++++++++++------ src/modules/ForceCollision/index.ts | 8 +++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/modules/ForceCollision/force-collision-spatial.frag b/src/modules/ForceCollision/force-collision-spatial.frag index 827dca67..3354e009 100644 --- a/src/modules/ForceCollision/force-collision-spatial.frag +++ b/src/modules/ForceCollision/force-collision-spatial.frag @@ -91,18 +91,24 @@ void main() { 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 9d9253da..91e9e66c 100644 --- a/src/modules/ForceCollision/index.ts +++ b/src/modules/ForceCollision/index.ts @@ -76,9 +76,11 @@ 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 this.gridTextureSize = Math.min( From b58ce4b90f1a2a8ebfb631ac470eff5a857fb1bf Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 4 Aug 2026 23:06:18 +0500 Subject: [PATCH 10/19] fix(helper): pad instance id words to a fixed width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generateRandomId` concatenated two base-36 uint32 words without a fixed boundary, so distinct word pairs could produce the same id — (1, 1261) and (71, 1) both encode to "1z1". The id namespaces the `document` listeners each Graph instance registers, and a shared id is exactly the condition where instances remove each other's handlers. Each word is now padded to the 7 base-36 digits a full uint32 needs (36^6 < 2^32 < 36^7), which makes the encoding injective: every id is 14 characters and splits at a fixed offset. Scope, measured rather than assumed: with crypto-random 32-bit words both halves are 6-7 digits, so the ambiguity was nearly unreachable — 400,000 generated ids collided zero times and the lost entropy was 0.08 bits of 64. This buys correctness by construction, not a fix for observed breakage. Verified after the change: 200,006 pairs including both extremes round-trip exactly, all 14 characters, no collisions. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/helper.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/helper.ts b/src/helper.ts index 3e8fe752..06a55fd8 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -96,7 +96,10 @@ export function readPixels (device: Device, fbo: Framebuffer, sourceX = 0, sourc export function generateRandomId (): string { const words = new Uint32Array(2) crypto.getRandomValues(words) - return Array.from(words, (word) => word.toString(36)).join('') + // 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('') } /** From f341f6e1795862be8ecead5af0c512ca50ebf4c3 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 4 Aug 2026 23:12:15 +0500 Subject: [PATCH 11/19] fix(force): keep the fitted collision cell at the interaction range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cell was sized to the full interaction range and then immediately undone. Fitting it to a whole number of grid cells rounded the grid dimension *up*, which divides the space into cells slightly smaller than requested, and a 32-cell minimum pinned the cell at spaceSize / 32 no matter how large the radius grew — so above an effective radius of 64 (point size 128 at the default space size) the coverage gap the previous commit closed reopened completely. - The grid dimension now rounds down. Fitting can only grow the cell, so the range the shader's adjacent-cell search relies on always survives it. The shader is untouched. - The lower clamp drops from 32 cells to 1. A large radius legitimately wants a coarse grid; refusing to go below 32 was refusing the cell size the physics asks for. At one cell every point shares it and all pairs are still compared, which is the degenerate case where the interaction range covers the whole space. Measured on settled simulations with collision as the only active force: 30 points at size 300 left 26 overlapping pairs, worst 22% of a diameter interpenetrating, and now leave none. Sizes 8, 30 and 100 stay at zero, so nothing that already worked is disturbed. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/modules/ForceCollision/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/modules/ForceCollision/index.ts b/src/modules/ForceCollision/index.ts index 91e9e66c..e36099cb 100644 --- a/src/modules/ForceCollision/index.ts +++ b/src/modules/ForceCollision/index.ts @@ -82,13 +82,16 @@ export class ForceCollision extends CoreModule { // 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 From 5acb03d7b271e9a046b69db0ade1425cd14aa226 Mon Sep 17 00:00:00 2001 From: Nikita Rokotyan Date: Tue, 4 Aug 2026 14:08:29 -0700 Subject: [PATCH 12/19] fix(links): unblended link edges: opaque hard cut without losing thin strokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `linkBlending: false`, soft AA fringes still wrote full RGB with partial alpha. Canvas compositing treats that as premultiplied, so edges read brighter than the solid core (tubular / outlined look). A naive hard cut at `coverage < 0.5` would "fix" the halo but erase thin links — when stroke width ≤ the AA kernel, the descending `smoothstep` only peaks around `0.5` at the centerline, so that threshold discards almost every fragment (visible in the hyperbolic large-graph story with `linkDefaultWidth: 0.5`). **What changed:** The link fragment shader splits geometric `coverage` from color alpha, passes `linkBlending` as a trailing fragment uniform, and when blending is off discards only zero-coverage / fully transparent fragments then writes `vec4(color, 1.0)`. Blended and picking paths are unchanged. Signed-off-by: Nikita Rokotyan --- src/config.ts | 6 ++-- src/modules/Lines/draw-curve-line.frag | 38 ++++++++++++++++++-------- src/modules/Lines/index.ts | 6 ++++ src/stories/configuration.mdx | 2 +- 4 files changed, 37 insertions(+), 15 deletions(-) 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/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/index.ts b/src/modules/Lines/index.ts index 24491318..27d26f6b 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -120,6 +120,7 @@ export class Lines extends CoreModule { linkColorInterpolateFromEndpoints: number; hoveredLinkIndex: number; hoveredLinkColor: [number, number, number, number]; + linkBlending: number; }; }> | undefined @@ -236,6 +237,7 @@ export class Lines extends CoreModule { linkColorInterpolateFromEndpoints: 'f32', hoveredLinkIndex: 'f32', hoveredLinkColor: 'vec4', + linkBlending: 'f32', }, defaultUniforms: { renderMode: 0.0, @@ -244,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, }, }, }) @@ -364,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, }, }) @@ -894,6 +898,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/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` | From 081dc20c587a7168d43c21beee023013c30c6b1b Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 5 Aug 2026 14:54:40 +0500 Subject: [PATCH 13/19] fix(data): reject link endpoints that are not real points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An odd-length links array made `linksNumber` fractional, and `new Array(linksNumber)` in updateArrows threw from inside the deferred render — the canvas went blank, points included, every public getter threw afterwards, and nothing reached the console. Endpoints themselves were never checked either: out-of-range, negative, fractional and NaN values reached the adjacency lists and the GPU untouched, so getNeighboringPointIndices reported points the caller cannot look up (`[99]`, `[-5]`, `[1.5]`) and a bogus link was drawn to whatever texel the index happened to address. Links now hold whole [source, target] pairs of real point indices. - The odd trailing value is dropped up front. `subarray` is a view, so the caller's array is not edited, and the check is a length test — no scan of the data. - Endpoints are validated where the links are already walked: the adjacency build skips an invalid pair, and the Lines endpoint buffer collapses one onto a single texel, which renders nothing (a zero-length link draws no pixels). Neither adds a pass. - Invalid links are neutralised in place, never removed. Link indices are part of the public API — onLinkClick, onLinkMouseOver, focusedLinkIndex, highlightedLinkIndices — and compacting the array would silently renumber every link after a dropped one. - `isPointIndex` replaces the same range test hand-written in getNeighboringPointIndices, getConnectedLinkIndices and getPointRadiusByIndex, which now also reject a fractional index instead of quietly returning nothing. - `pair()` had the same fractional-length flaw and threw a RangeError on an odd array; it now drops the unpaired trailing value. Verified: an odd links array renders exactly as the even prefix instead of blanking the canvas; out-of-range, negative, fractional and NaN endpoints each report no neighbours and draw zero pixels while the valid links in the same array are untouched; and a valid link *after* an invalid one keeps its original index. Co-authored-by: Nikita Rokotyan Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/index.ts | 9 +++++--- src/modules/GraphData/index.ts | 40 +++++++++++++++++++++++++--------- src/modules/Lines/index.ts | 11 ++++++++-- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7ab63518..13836897 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1324,7 +1324,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) @@ -1685,8 +1685,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] } diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index 17a02deb..2451ac72 100644 --- a/src/modules/GraphData/index.ts +++ b/src/modules/GraphData/index.ts @@ -279,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 } @@ -451,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) } @@ -469,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) } @@ -513,12 +529,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/index.ts b/src/modules/Lines/index.ts index 27d26f6b..7176335b 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -482,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 From 61c27ae21181cdda7cec4352f0f6a0fcc30e7281 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 5 Aug 2026 16:02:39 +0500 Subject: [PATCH 14/19] fix(data): validate endpoints in getConnectedPointIndices too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint validation added with the link fixes covered the adjacency lists and the render path, but `getConnectedPointIndices` reads `links` directly and so bypassed both — asked about a link whose target is not a real point, it returned that index anyway. With 3 points and a link [0, 3] it answered [0, 3], handing the caller a point that does not exist; a multi-link query mixed the phantom in with valid endpoints. - Both endpoints are checked with `isPointIndex`, and a link with either one invalid contributes neither. That matches what the adjacency build already skips, so the two readers now agree. - The link index itself must be an integer. `0.5` passed the range test and then read `links[1]` and `links[2]` — one endpoint from each of two neighbouring links — reporting a pair that was never a link. The other index getters already reject a fractional index. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/modules/GraphData/index.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index 2451ac72..3165ff5d 100644 --- a/src/modules/GraphData/index.ts +++ b/src/modules/GraphData/index.ts @@ -508,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] } From 3f3a3b81093589db6351771bb2a5e6695713e7a4 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 10 Aug 2026 13:46:33 +0500 Subject: [PATCH 15/19] fix(points): tracked points follow the point, not a baked texel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trackPointPositionsByIndices baked each tracked index into its texel of the position grid once, at call time. The grid width is ceil(sqrt(count)), so any later point-count change relaid the grid out while the table kept addressing the old layout: a tracked label silently followed whichever point now occupied the stale texel, and an index with no point behind it read out of range and reported (0, 0) — a coordinate that looks legitimate. No stage failed, so nothing surfaced. The table now stores the raw point index — the question, not the answer — and the shader derives the texel at read time from the width the positions texture has right now (textureSize(), valid here because the shader samples that texture). Staleness is not fixed but inexpressible: there is no baked mapping left to rot, and no new state tracking it. - precision highp int is declared: raw indices exceed mediump int's 16-bit spec minimum, and fragment shaders default to mediump. Integer texel math is required — a GPU probe showed float mod(33.0, 33.0) returning 33 (the boundary-floor failure the texelFetch rework documented), while the int path was exact for every width at indices up to 2^24 - 1. - float32 carries an index exactly to 2^24 — the ceiling every float-carried index in the engine already lives under. - The tracked set becomes declarative: an index follows its point whenever the point exists. The map omits an index at or past the current count (the absent-point contract) and the entry returns if the count grows back; the array keeps the slot as NaN to stay aligned. Both readbacks guard with isPointIndex. - The bake no longer needs the grid width, so tracking can be set up before the first setPointPositions call. Verified in Storybook against real Graph instances, points laid out exactly as the internal grid stores them: growing 9 -> 16 relayouts the grid and the tracked entry follows point 5's new position instead of reporting point 6 at the stale texel; re-tracking is a no-op; shrinking below the index yields no entry and growing back restores it; a NaN-removed point stays omitted; tracking [99] on 9 points yields an empty map. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/index.ts | 9 +++++++- src/modules/Points/index.ts | 30 +++++++++++++++++-------- src/modules/Points/track-positions.frag | 20 +++++++++++++---- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/src/index.ts b/src/index.ts index 13836897..07846ca4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1332,6 +1332,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 { @@ -1350,7 +1356,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() diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 7123b77e..e0959133 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -2265,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 } } @@ -2347,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() @@ -2357,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]) } @@ -2479,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] @@ -2487,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 diff --git a/src/modules/Points/track-positions.frag b/src/modules/Points/track-positions.frag index 0d7594c1..e25cb4e1 100644 --- a/src/modules/Points/track-positions.frag +++ b/src/modules/Points/track-positions.frag @@ -1,6 +1,9 @@ #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; @@ -11,10 +14,19 @@ out vec4 fragColor; void main() { ivec2 trackedTexel = ivec2(gl_FragCoord.xy); - vec4 trackedPointIndices = texelFetch(trackedIndices, trackedTexel, 0); - if (trackedPointIndices.r < 0.0) discard; - vec4 pointPosition = texelFetch(positionsTexture, ivec2(trackedPointIndices.rg), 0); + // 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); } - From d7a5225b22d510f65974522d19efacdc6148b0c5 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 10 Aug 2026 16:34:55 +0500 Subject: [PATCH 16/19] fix(points): close the lasso with integer modulo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrap-around vertex used float mod — a WebGL 1 leftover. GPU division is reciprocal-based, so mod(N, N) can return N (33 fails on ANGLE Metal; 32 and 100 are exact): the last edge then closed onto a zero-filled padding texel at (0, 0), and a 33-vertex lasso selected four outsiders while dropping the point it enclosed. Integer % cannot round. A 32-vertex control selects exactly the enclosed point before and after. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/modules/Points/find-points-in-polygon.frag | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/Points/find-points-in-polygon.frag b/src/modules/Points/find-points-in-polygon.frag index 0b9ae6c2..69f92035 100644 --- a/src/modules/Points/find-points-in-polygon.frag +++ b/src/modules/Points/find-points-in-polygon.frag @@ -49,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); From 71206e231d27200be434000ad26c2f2f0688f790 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 10 Aug 2026 16:34:55 +0500 Subject: [PATCH 17/19] fix(force): declare highp int for near-field point indices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fragment shaders default int to mediump — a 16-bit spec minimum, and SwiftShader reports exactly 16. The near-field pass decomposes a raw point index with % and /, so past 32 767 points the fetch could land on the wrong texel. Same declaration as track-positions.frag; verified behavior-neutral on ANGLE Metal (repulsion identical before and after). Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/modules/ForceManyBody/force-nearfield.frag | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/modules/ForceManyBody/force-nearfield.frag b/src/modules/ForceManyBody/force-nearfield.frag index 3132ed26..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 From c5f3cf30df83b6928f3eae1557fdf7d272179769 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 10 Aug 2026 16:46:02 +0500 Subject: [PATCH 18/19] docs(history): record the tracked-index redesign and int-precision findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data-texture-addressing entry recorded the tracked-index re-bake as owed; it is now resolved by the entry's own rule — the table stores raw indices and the shader derives texels at read time. Also recorded: the WebGL 1 origin of the half-texel idiom (GLSL ES 1.00 reserved %), the lasso closing-edge fix as the codebase's live instance of the measured mod(N, N) failure, the highp int declarations in the two raw-index fragment shaders with SwiftShader's 16-bit mediump report as evidence, and the deliberate decisions to leave bounded ints and sampler precision at their defaults. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- .../2026-07-30-data-texture-addressing.md | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/history/2026/2026-07-30-data-texture-addressing.md b/history/2026/2026-07-30-data-texture-addressing.md index c24aa86a..d71a93d5 100644 --- a/history/2026/2026-07-30-data-texture-addressing.md +++ b/history/2026/2026-07-30-data-texture-addressing.md @@ -3,7 +3,9 @@ # Addressing data textures by texel index **Commits:** `fix(shaders): read data textures by texel index, never by coordinate` -(`f6c6a97`) +(`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 @@ -52,7 +54,12 @@ fragility — it is an argument that has to be re-made at every new call site, a 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. +`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 @@ -84,12 +91,18 @@ local of the same name). That one now asks the texture. 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.** `trackPointPositionsByIndices` bakes its texel pairs - from the `pointsTextureSize` current at call time and never re-bakes them when - the point count changes. A stale index used to clamp and report *some* real - point's position; it now reads out of range and reports `(0, 0)`, so a tracked - set lands at the origin and reads like a layout bug. Pre-existing — the symptom - changed, and the re-bake is still owed. +- **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 @@ -124,5 +137,26 @@ hardware was exercised. 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. From c42b41daaa826d227aafcdc782d1758a5c19cd7c Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 11 Aug 2026 10:42:30 +0500 Subject: [PATCH 19/19] fix(docs): make render/create/start/unpause docs match the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public docs promised contracts the code never kept. render() claimed it "does NOT modify simulation state" — but the simulationAlpha argument sets the alpha, and starting a position transition pauses a running simulation (behavior added with the GPU-transition work; the claim predates it). create() claimed to apply pending data changes "without calling render()" — but it never ingests input arrays (graph.update() runs only in the render path), so set* data cannot take effect through it; that wording came from a docs sweep and described intent, not behavior. start() claimed to control "only the simulation state, not rendering" while requesting frames and force-ending an active position transition; unpause() force-ends transitions too and said nothing. - render(): state the real contract — no simulation start/stop, with the two exceptions named by argument (simulationAlpha, transitionDuration). - create(): describe it as the flag-gated GPU upload stage of the render pipeline, and point at render(undefined) / render(undefined, 0) for applying new data; drop the stale "public contract" inline comment. - start()/unpause(): name the position-transition interruption (onTransitionEnd fires with interrupted: true), matching what the configuration docs already document under onTransitionEnd. - Mirror all four entries in the Storybook API reference. Docs now promise exactly what the code does. Co-Authored-By: Claude Fable 5 Signed-off-by: Stukova Olya --- src/index.ts | 26 ++++++++++++++++++-------- src/stories/api-reference.mdx | 8 ++++---- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 07846ca4..cad0b4c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -918,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). @@ -1442,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. @@ -1499,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 @@ -1618,9 +1625,12 @@ export class Graph { } /** - * 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 @@ -1673,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() } 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)