From 6e4471a0d8c8566f1f7bf4e0af87676ed8fb2430 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 8 Jun 2026 13:39:13 +0200 Subject: [PATCH 1/3] fix: correct global bind key --- src/viewer.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/viewer.ts b/src/viewer.ts index e7839ffab..2c740bb3d 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -1172,14 +1172,13 @@ export class Viewer extends RefCounted implements ViewerState { openPalette = new CommandPalette(this, dispatchTarget); }; this.bindAction("open-command-palette", openCommandPalette); - // Document-level capture fires before bubble handlers, ensuring F1 works - // even when focus is inside a tool's input element outside viewer.element. + // Document-level capture to ensure that the command palette opens even when focus is inside a tool's input element outside viewer.element. this.registerDisposer( registerEventListener( document, "keydown", (event: KeyboardEvent) => { - if (event.code === "F1") { + if (event.code === "KeyP" && event.ctrlKey) { event.preventDefault(); openCommandPalette(); } From 435267026b6819e871273180ba119f109027a1b8 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 8 Jun 2026 14:30:39 +0200 Subject: [PATCH 2/3] fix: allow seg ID to use display state --- src/skeleton/frontend.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index c97e30a0b..2abc095a3 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -2174,20 +2174,15 @@ export class SpatiallyIndexedSkeletonLayer if (isCacheValid) { return this.selectedNodeOutlineColor; } - const segmentId = this.getCachedNodeSnapshot(selectedNodeId)?.segmentId; - const normalizedSegmentId = Math.round(Number(segmentId)); - if ( - !Number.isSafeInteger(normalizedSegmentId) || - normalizedSegmentId <= 0 - ) { - this.cachedSelectedNodeOutlineColorGeneration = currentGeneration; - this.selectedNodeOutlineColor.set(SELECTED_NODE_OUTLINE_FALLBACK_COLOR); + const segmentId = this.displayState.segmentSelectionState.baseValue; + if (segmentId === undefined) { return SELECTED_NODE_OUTLINE_FALLBACK_COLOR; } + this.cachedSelectedNodeOutlineColorGeneration = currentGeneration; return computeHighVisibilityContrastColor( this.selectedNodeOutlineColor, - getBaseObjectColor(this.displayState, BigInt(normalizedSegmentId)), + getBaseObjectColor(this.displayState, segmentId), ); } From 970b290748f6a4b021a7974a2e3287936c4f3e1b Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Mon, 8 Jun 2026 14:43:34 +0200 Subject: [PATCH 3/3] feat: allow highlighting nodes from spatial index main changes are: 1. Cleaner repr of selected texture. Now stores a nodeID per vertex, instead of updating a 1D texture which effectively stores a bool of the highlihgted node. Then passes a uniform about the current node. 2. Removes now uneeded 1D texture update. 3. Cleans up some shader ternary ops to use mix. 4. Uses less GPU mem in overlay pass. As a note, this also would allow to do per node discarding which is something we discussed previously if ever really needed. --- src/skeleton/frontend.spec.ts | 79 +-------- src/skeleton/frontend.ts | 213 +++++++++-------------- src/skeleton/segment_overlay.spec.ts | 2 - src/skeleton/segment_overlay.ts | 21 +-- src/ui/command_palette.spec.ts | 4 +- src/ui/skeleton_tab.ts | 2 +- src/webgl/texture_access.browser_test.ts | 155 ----------------- src/webgl/texture_access.ts | 44 ----- 8 files changed, 105 insertions(+), 415 deletions(-) diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index 6fc32c77c..d6670a78d 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -129,13 +129,8 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { }, saturation: { value: 0 }, hoverHighlight: { value: true }, - segmentSelectionState: { isSelected }, + segmentSelectionState: { isSelected, baseValue: 101n }, }; - const getCachedNodeSnapshot = vi.fn(() => ({ - nodeId: 101, - segmentId: 11, - position: new Float32Array([1, 2, 3]), - })); const layer = Object.assign( Object.create(SpatiallyIndexedSkeletonLayer.prototype), { @@ -144,15 +139,12 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { selectedNodeOutlineColorGeneration: 0, cachedSelectedNodeOutlineColorGeneration: -1, displayState, - getCachedNodeSnapshot, }, ); const outlineColor = (layer as any).getSelectedNodeOutlineColor(); const cachedOutlineColor = (layer as any).getSelectedNodeOutlineColor(); - expect(getCachedNodeSnapshot).toHaveBeenCalledWith(101); - expect(getCachedNodeSnapshot).toHaveBeenCalledTimes(1); expect(isSelected).not.toHaveBeenCalled(); expect(cachedOutlineColor).toBe(outlineColor); expect(outlineColor[0]).toBeCloseTo(1); @@ -181,13 +173,11 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { }, saturation: { value: 1 }, hoverHighlight: { value: false }, - segmentSelectionState: { isSelected: vi.fn(() => false) }, + segmentSelectionState: { + isSelected: vi.fn(() => false), + baseValue: 101n, + }, }; - const getCachedNodeSnapshot = vi.fn((nodeId: number) => ({ - nodeId, - segmentId: 11, - position: new Float32Array([1, 2, 3]), - })); const layer = Object.assign( Object.create(SpatiallyIndexedSkeletonLayer.prototype), { @@ -196,7 +186,6 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { selectedNodeOutlineColorGeneration: 0, cachedSelectedNodeOutlineColorGeneration: -1, displayState, - getCachedNodeSnapshot, }, ); @@ -205,7 +194,6 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { ++(layer as any).selectedNodeOutlineColorGeneration; (layer as any).getSelectedNodeOutlineColor(); - expect(getCachedNodeSnapshot).toHaveBeenCalledTimes(2); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); @@ -226,13 +214,11 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { }, saturation: { value: 1 }, hoverHighlight: { value: false }, - segmentSelectionState: { isSelected: vi.fn(() => false) }, + segmentSelectionState: { + isSelected: vi.fn(() => false), + baseValue: 101n, + }, }; - const getCachedNodeSnapshot = vi.fn(() => ({ - nodeId: 101, - segmentId: 11, - position: new Float32Array([1, 2, 3]), - })); const layer = Object.assign( Object.create(SpatiallyIndexedSkeletonLayer.prototype), { @@ -241,7 +227,6 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { selectedNodeOutlineColorGeneration: 0, cachedSelectedNodeOutlineColorGeneration: -1, displayState, - getCachedNodeSnapshot, }, ); @@ -249,54 +234,8 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { ++(layer as any).selectedNodeOutlineColorGeneration; (layer as any).getSelectedNodeOutlineColor(); - expect(getCachedNodeSnapshot).toHaveBeenCalledTimes(2); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); - - it("returns the fallback outline color for an invalid selected segment", () => { - const computeSegmentColor = vi.fn(); - const displayState = { - segmentationColorGroupState: { - value: { - segmentStatedColors: new Map(), - segmentDefaultColor: { value: undefined }, - segmentColorHash: { compute: computeSegmentColor }, - }, - }, - saturation: { value: 1 }, - hoverHighlight: { value: false }, - segmentSelectionState: { isSelected: vi.fn(() => false) }, - }; - const getCachedNodeSnapshot = vi.fn(() => ({ - nodeId: 101, - segmentId: 0, - position: new Float32Array([1, 2, 3]), - })); - const layer = Object.assign( - Object.create(SpatiallyIndexedSkeletonLayer.prototype), - { - selectedNodeId: { value: 101 }, - selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, - displayState, - getCachedNodeSnapshot, - }, - ); - - const outlineColor = (layer as any).getSelectedNodeOutlineColor(); - const cachedOutlineColor = (layer as any).getSelectedNodeOutlineColor(); - - expect(getCachedNodeSnapshot).toHaveBeenCalledWith(101); - expect(getCachedNodeSnapshot).toHaveBeenCalledTimes(1); - expect(computeSegmentColor).not.toHaveBeenCalled(); - expect(outlineColor[0]).toBeCloseTo(1); - expect(outlineColor[1]).toBeCloseTo(0.95); - expect(outlineColor[2]).toBeCloseTo(0.35); - expect(cachedOutlineColor[0]).toBeCloseTo(1); - expect(cachedOutlineColor[1]).toBeCloseTo(0.95); - expect(cachedOutlineColor[2]).toBeCloseTo(0.35); - }); }); describe("SpatiallyIndexedSkeletonLayer targeted source invalidation", () => { diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 2abc095a3..24c772ac8 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -107,6 +107,7 @@ import type { SliceViewPanelReadyRenderContext, } from "#src/sliceview/renderlayer.js"; import { SliceViewPanelRenderLayer } from "#src/sliceview/renderlayer.js"; +import { TrackableBoolean } from "#src/trackable_boolean.js"; import type { WatchableValueInterface } from "#src/trackable_value.js"; import { makeCachedLazyDerivedWatchableValue, @@ -118,7 +119,6 @@ import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; import { computeHighVisibilityContrastColor } from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; -import { TrackableBoolean } from "#src/trackable_boolean.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; import type { ValueOrError } from "#src/util/error.js"; @@ -178,7 +178,6 @@ import { OneDimensionalTextureAccessHelper, setOneDimensionalTextureData, TextureFormat, - updateOneDimensionalTextureElement, } from "#src/webgl/texture_access.js"; import { defineVertexId, VertexIdHelper } from "#src/webgl/vertex_id.js"; import type { RPC } from "#src/worker_rpc.js"; @@ -190,8 +189,6 @@ const DEBUG_SPATIAL_SKELETON_CHUNKS = false; const tempChunkKeyToColorMap = new Map(); const tempMat4 = mat4.create(); -const OVERLAY_SELECTED_FLOAT_ZERO = new Float32Array([0]); -const OVERLAY_SELECTED_FLOAT_ONE = new Float32Array([1]); const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } @@ -286,7 +283,7 @@ class RenderHelper extends RefCounted { private vertexIdHelper; private segmentAttributeIndex: number | undefined; private segmentColorAttributeIndex: number | undefined; - private selectedNodeAttributeIndex: number | undefined; + private nodeIdAttributeIndex: number | undefined; private visibleSegmentsShaderManager = new HashSetShaderManager( "visibleSegments", ); @@ -369,10 +366,7 @@ void spatialChunkCull() { ); } for (let i = 1; i < numAttributes; ++i) { - if ( - i === this.segmentAttributeIndex || - i === this.selectedNodeAttributeIndex - ) { + if (i === this.segmentAttributeIndex || i === this.nodeIdAttributeIndex) { continue; } const info = vertexAttributes[i]; @@ -461,11 +455,9 @@ void spatialChunkCull() { const hoverAdjustFragment = params.hoverHighlight ? ` - if (segmentId.value.x == uHoveredSegmentId.x && - segmentId.value.y == uHoveredSegmentId.y) { - if (saturation > 0.5) { saturation -= 0.5; } - else { saturation += 0.5; } - }` + float isHovered = float(segmentId.value.x == uHoveredSegmentId.x && + segmentId.value.y == uHoveredSegmentId.y); + saturation += isHovered * (0.5 - step(0.5, saturation));` : ""; builder.addFragmentCode(` @@ -600,11 +592,11 @@ vec4 getSegmentAppearance(highp uint segmentValue) { this.segmentAttributeIndex = segmentAttrIndex >= 0 ? segmentAttrIndex : undefined; this.segmentColorAttributeIndex = base.segmentColorAttributeIndex; - const selectedNodeAttrIndex = this.vertexAttributes.findIndex( - (x) => x.name === selectedNodeAttribute.name, + const nodeIdAttrIndex = this.vertexAttributes.findIndex( + (x) => x.name === nodeIdAttribute.name, ); - this.selectedNodeAttributeIndex = - selectedNodeAttrIndex >= 0 ? selectedNodeAttrIndex : undefined; + this.nodeIdAttributeIndex = + nodeIdAttrIndex >= 0 ? nodeIdAttrIndex : undefined; const segmentationGroupState = base.displayState.segmentationGroupState.value; @@ -766,8 +758,9 @@ void emitDefault() { ); builder.addUniform("highp float", "uNodeDiameter"); let selectedOutlineWidthExpression = "0.0"; - if (this.selectedNodeAttributeIndex !== undefined) { + if (this.nodeIdAttributeIndex !== undefined) { builder.addUniform("highp vec3", "uSelectedNodeOutlineColor"); + builder.addUniform("highp int", "uSelectedNodeId"); builder.addVarying("highp float", "vSelectedNode", "flat"); const selectedOutlineMinWidth = this.targetIsSliceView ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D @@ -775,7 +768,7 @@ void emitDefault() { const selectedOutlineMaxWidth = this.targetIsSliceView ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D; - selectedOutlineWidthExpression = `((vSelectedNode > 0.5) ? clamp(0.25 * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}) : 0.0)`; + selectedOutlineWidthExpression = `(vSelectedNode * clamp(0.25 * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); @@ -786,8 +779,8 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); if (skeletonParams.spatialChunkCulling) { vertexMain += `vCullPos = vertexPosition;\n`; } - if (this.selectedNodeAttributeIndex !== undefined) { - vertexMain += `vSelectedNode = readAttribute${this.selectedNodeAttributeIndex}(vertexIndex);\n`; + if (this.nodeIdAttributeIndex !== undefined) { + vertexMain += `vSelectedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uSelectedNodeId);\n`; } if ( skeletonParams.dynamicSegmentAppearance && @@ -811,14 +804,10 @@ emitCircle( // saturation and hover highlight all resolved in the shader via // getSegmentAppearance(). uColor is unused in this path. const segmentExpression = `vSegmentValue`; - const selectedNodeExpression = - this.selectedNodeAttributeIndex === undefined - ? undefined - : "vSelectedNode"; - const borderColorExpression = - selectedNodeExpression === undefined - ? "renderColor" - : `((${selectedNodeExpression} > 0.5) ? vec4(uSelectedNodeOutlineColor, renderColor.a) : renderColor)`; + const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + const borderColorExpression = hasNodeIdSelection + ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { return getSegmentAppearance(${segmentExpression}); @@ -861,14 +850,10 @@ void emitDefault() { } else { // Per-vertex color attribute path: color comes from a per-vertex // attribute; alpha is taken from the attribute's alpha component. - const selectedNodeExpression = - this.selectedNodeAttributeIndex === undefined - ? undefined - : "vSelectedNode"; - const borderColorExpression = - selectedNodeExpression === undefined - ? "renderColor" - : `((${selectedNodeExpression} > 0.5) ? vec4(uSelectedNodeOutlineColor, renderColor.a) : renderColor)`; + const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + const borderColorExpression = hasNodeIdSelection + ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { return ${segmentColorExpression}; @@ -1564,12 +1549,12 @@ const segmentAttribute: VertexAttributeRenderInfo = { glslDataType: getShaderType(DataType.UINT32, 1), }; -const selectedNodeAttribute: VertexAttributeRenderInfo = { - dataType: DataType.FLOAT32, +const nodeIdAttribute: VertexAttributeRenderInfo = { + dataType: DataType.INT32, numComponents: 1, - name: "selectedNodeAttr", - webglDataType: WebGL2RenderingContext.FLOAT, - glslDataType: "float", + name: "nodeId", + webglDataType: WebGL2RenderingContext.INT, + glslDataType: getShaderType(DataType.INT32, 1), }; interface SkeletonChunkBase extends SkeletonGPUGeometry { @@ -1687,6 +1672,31 @@ export class SpatiallyIndexedSkeletonChunk copyToGPU(gl: GL) { super.copyToGPU(gl); uploadSkeletonChunkToGPU(gl, this); + // Upload nodeIds as the 3rd vertex attribute texture (index 2). + // vertexAttributeOffsets only covers position (0) and segment (1), so we + // handle nodeId separately here since it is stored outside the packed buffer. + const nodeIdFormat = this.source.attributeTextureFormats[2]; + if ( + nodeIdFormat !== undefined && + this.nodeIds.length === this.numVertices && + this.numVertices > 0 + ) { + const texture = gl.createTexture(); + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); + setOneDimensionalTextureData( + gl, + nodeIdFormat, + new Uint8Array( + this.nodeIds.buffer, + this.nodeIds.byteOffset, + this.nodeIds.byteLength, + ), + ); + gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); + this.vertexAttributeTextures[2] = texture; + } else { + this.vertexAttributeTextures[2] = null; + } } freeGPUMemory(gl: GL) { @@ -1703,6 +1713,7 @@ type SpatiallyIndexedSkeletonChunkListener = ( const spatiallyIndexedSkeletonTextureAttributeSpecs = Object.freeze([ { name: "position", dataType: DataType.FLOAT32, numComponents: 3 }, { name: "segment", dataType: DataType.UINT32, numComponents: 1 }, + { name: "nodeId", dataType: DataType.INT32, numComponents: 1 }, ]); export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< @@ -1715,7 +1726,11 @@ export class SpatiallyIndexedSkeletonSource extends SliceViewChunkSource< constructor(chunkManager: ChunkManager, options: any) { super(chunkManager, options); - this.vertexAttributes = [vertexPositionAttribute, segmentAttribute]; + this.vertexAttributes = [ + vertexPositionAttribute, + segmentAttribute, + nodeIdAttribute, + ]; } get attributeTextureFormats() { @@ -1834,8 +1849,6 @@ class SkeletonOverlayChunk implements SkeletonGPUGeometry { readonly pickNodePositions: Float32Array; readonly pickSegmentIds: Uint32Array; readonly pickEdgeSegmentIds: Uint32Array; - private readonly nodeIdToVertexIndex: Map; - private readonly selectedFormat: TextureFormat; constructor( gl: GL, @@ -1854,9 +1867,9 @@ class SkeletonOverlayChunk implements SkeletonGPUGeometry { geometry.segmentIds.byteLength, ), new Uint8Array( - geometry.selected.buffer, - geometry.selected.byteOffset, - geometry.selected.byteLength, + geometry.nodeIds.buffer, + geometry.nodeIds.byteOffset, + geometry.nodeIds.byteLength, ), ]; const overlayTextures: (WebGLTexture | null)[] = @@ -1881,52 +1894,6 @@ class SkeletonOverlayChunk implements SkeletonGPUGeometry { this.pickNodePositions = geometry.positions; this.pickSegmentIds = geometry.pickSegmentIds; this.pickEdgeSegmentIds = geometry.pickEdgeSegmentIds; - const nodeIdToVertexIndex = new Map(); - const { nodeIds } = geometry; - for (let i = 0; i < nodeIds.length; i++) { - const nodeId = nodeIds[i]; - if (nodeId > 0) nodeIdToVertexIndex.set(nodeId, i); - } - this.nodeIdToVertexIndex = nodeIdToVertexIndex; - this.selectedFormat = formats[2]; - } - - // Updates the selected-node highlight in-place without a full GPU rebuild. - // Clears oldNodeId's texel and sets newNodeId's texel. - updateSelectedNode( - gl: GL, - oldNodeId: number | undefined, - newNodeId: number | undefined, - ) { - if (oldNodeId === newNodeId) return; - const texture = this.vertexAttributeTextures[2]; - if (texture === null) return; - if (oldNodeId !== undefined) { - const idx = this.nodeIdToVertexIndex.get(oldNodeId); - if (idx !== undefined) { - updateOneDimensionalTextureElement( - gl, - texture, - this.selectedFormat, - this.numVertices, - idx, - OVERLAY_SELECTED_FLOAT_ZERO, - ); - } - } - if (newNodeId !== undefined) { - const idx = this.nodeIdToVertexIndex.get(newNodeId); - if (idx !== undefined) { - updateOneDimensionalTextureElement( - gl, - texture, - this.selectedFormat, - this.numVertices, - idx, - OVERLAY_SELECTED_FLOAT_ONE, - ); - } - } } dispose(gl: GL) { @@ -2058,7 +2025,7 @@ export class SpatiallyIndexedSkeletonLayer redrawNeeded = new NullarySignal(); vertexAttributes: VertexAttributeRenderInfo[]; segmentColorAttributeIndex: number | undefined; - selectedNodeAttributeIndex: number | undefined; + nodeIdAttributeIndex: number | undefined; readonly browsePassLayerView: SkeletonShaderContext; readonly skeletonShaderParameters: WatchableValue; readonly browsePassSkeletonShaderParameters: WatchableValueInterface; @@ -2093,9 +2060,7 @@ export class SpatiallyIndexedSkeletonLayer | undefined; private inspectionState: SpatiallyIndexedSkeletonInspectionState | undefined; private overlayChunk: SkeletonOverlayChunk | undefined; - private overlayChunkKey: string | undefined; private overlayGeometryKey: string | undefined; - private cachedSelectedNodeId: number | undefined; private overlayRebuildFrame = -1; private pendingOverlaySegmentLoads = new Set(); private browseExcludedSegments = new Uint64Set(); @@ -2113,9 +2078,7 @@ export class SpatiallyIndexedSkeletonLayer private disposeOverlayChunk() { this.overlayChunk?.dispose(this.gl); this.overlayChunk = undefined; - this.overlayChunkKey = undefined; this.overlayGeometryKey = undefined; - this.cachedSelectedNodeId = undefined; } private requestOverlaySegmentLoad(segmentId: number) { @@ -2330,27 +2293,16 @@ export class SpatiallyIndexedSkeletonLayer } const overlayGeometryKey = this.getOverlayGeometryKey(loadedSegmentIds); - const selectedNodeId = this.selectedNodeId?.value; - const overlayChunkKey = `${overlayGeometryKey}|selected:${selectedNodeId ?? ""}`; if (this.overlayChunk !== undefined) { if (this.overlayGeometryKey === overlayGeometryKey) { - // Geometry unchanged — update only the selected-node highlight in-place - // rather than reallocating all GPU textures. - if (this.overlayChunkKey !== overlayChunkKey) { - this.overlayChunk.updateSelectedNode( - this.gl, - this.cachedSelectedNodeId, - selectedNodeId, - ); - this.overlayChunkKey = overlayChunkKey; - this.cachedSelectedNodeId = selectedNodeId; - } + // Geometry unchanged — selection is driven by uSelectedNodeId uniform + // at draw time, so no GPU rebuild is needed when selection changes. return this.overlayChunk; } } - // Pass 2: geometry cache miss — collect node sets and rebuild. + // Geometry cache miss — collect node sets and rebuild. const segmentNodeSets: (readonly SpatiallyIndexedSkeletonNode[])[] = []; for (const segmentId of loadedSegmentIds) { const segmentNodes = @@ -2362,19 +2314,14 @@ export class SpatiallyIndexedSkeletonLayer this.disposeOverlayChunk(); const geometry = buildSpatiallyIndexedSkeletonOverlayGeometry( segmentNodeSets, - { - selectedNodeId, - getPendingNodePosition: this.getPendingNodePositionOverride, - }, + { getPendingNodePosition: this.getPendingNodePositionOverride }, ); this.overlayChunk = new SkeletonOverlayChunk( this.gl, geometry, this.overlayAttributeTextureFormats, ); - this.overlayChunkKey = overlayChunkKey; this.overlayGeometryKey = overlayGeometryKey; - this.cachedSelectedNodeId = selectedNodeId; return this.overlayChunk; } @@ -2457,10 +2404,7 @@ export class SpatiallyIndexedSkeletonLayer }), ); - this.vertexAttributes = [ - ...this.source.vertexAttributes, - selectedNodeAttribute, - ]; + this.vertexAttributes = [...this.source.vertexAttributes]; this.skeletonShaderParameters = new WatchableValue({ dynamicSegmentAppearance: true, @@ -2538,11 +2482,10 @@ export class SpatiallyIndexedSkeletonLayer displayState: this.displayState, skeletonShaderParameters: this.browsePassSkeletonShaderParameters, }; - const selectedNodeIndex = this.vertexAttributes.findIndex( - (x) => x.name === selectedNodeAttribute.name, + const nodeIdIndex = this.vertexAttributes.findIndex( + (x) => x.name === nodeIdAttribute.name, ); - this.selectedNodeAttributeIndex = - selectedNodeIndex >= 0 ? selectedNodeIndex : undefined; + this.nodeIdAttributeIndex = nodeIdIndex >= 0 ? nodeIdIndex : undefined; const requestRedraw = () => this.redrawNeeded.dispatch(); const selectedNodeWatchable = this.selectedNodeId; if (selectedNodeWatchable?.changed) { @@ -2978,6 +2921,16 @@ export class SpatiallyIndexedSkeletonLayer if (passState === undefined) return; const { gl, edgeShader, nodeShader, skeletonParams } = passState; + nodeShader.bind(); + gl.uniform3fv( + nodeShader.uniform("uSelectedNodeOutlineColor"), + this.getSelectedNodeOutlineColor(), + ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeId?.value ?? -1, + ); + const chunkOrigin = vec3.create(); const chunkBound = vec3.create(); for (const { chunk, chunkLayout } of visibleChunks) { @@ -3096,6 +3049,10 @@ export class SpatiallyIndexedSkeletonLayer nodeShader.uniform("uSelectedNodeOutlineColor"), this.getSelectedNodeOutlineColor(), ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeId?.value ?? -1, + ); if (renderContext.emitPickID) { const edgePickId = diff --git a/src/skeleton/segment_overlay.spec.ts b/src/skeleton/segment_overlay.spec.ts index 63ad56df8..c49532db5 100644 --- a/src/skeleton/segment_overlay.spec.ts +++ b/src/skeleton/segment_overlay.spec.ts @@ -55,7 +55,6 @@ describe("buildSpatiallyIndexedSkeletonOverlayGeometry", () => { ], ], { - selectedNodeId: 2, getPendingNodePosition: (nodeId) => nodeId === 3 ? new Float32Array([70, 80, 90]) : undefined, }, @@ -64,7 +63,6 @@ describe("buildSpatiallyIndexedSkeletonOverlayGeometry", () => { expect(geometry.numVertices).toBe(3); expect([...geometry.nodeIds]).toEqual([1, 2, 3]); expect([...geometry.segmentIds]).toEqual([11, 11, 13]); - expect([...geometry.selected]).toEqual([0, 1, 0]); expect([...geometry.positions]).toEqual([1, 2, 3, 4, 5, 6, 70, 80, 90]); expect([...geometry.indices]).toEqual([1, 0]); expect([...geometry.pickEdgeSegmentIds]).toEqual([11]); diff --git a/src/skeleton/segment_overlay.ts b/src/skeleton/segment_overlay.ts index 91cb56c6c..822875f23 100644 --- a/src/skeleton/segment_overlay.ts +++ b/src/skeleton/segment_overlay.ts @@ -27,21 +27,19 @@ let gpuScratchCapacity = 0; // in vertices // Layout per capacity-slot (cap = gpuScratchCapacity): // [0, cap*4) — segmentIds (Uint32, 4 B/vertex) -// [cap*4, cap*8) — selected (Float32, 4 B/vertex) -// [cap*8, cap*16) — edgeIndices (Uint32 pairs, 8 B/vertex max) -// [cap*16, cap*20) — edgeSegIds (Uint32, 4 B/vertex max) +// [cap*4, cap*12) — edgeIndices (Uint32 pairs, 8 B/vertex max) +// [cap*12, cap*16) — edgeSegIds (Uint32, 4 B/vertex max) function ensureGpuScratch(numVertices: number) { if (numVertices > gpuScratchCapacity) { const cap = Math.max(numVertices, gpuScratchCapacity * 2, 64); - gpuScratchBuffer = new ArrayBuffer(cap * 20); + gpuScratchBuffer = new ArrayBuffer(cap * 16); gpuScratchCapacity = cap; } const cap = gpuScratchCapacity; return { segmentIds: new Uint32Array(gpuScratchBuffer, 0, numVertices), - selected: new Float32Array(gpuScratchBuffer, cap * 4, numVertices), - edgeIndices: new Uint32Array(gpuScratchBuffer, cap * 8, numVertices * 2), - edgeSegIds: new Uint32Array(gpuScratchBuffer, cap * 16, numVertices), + edgeIndices: new Uint32Array(gpuScratchBuffer, cap * 4, numVertices * 2), + edgeSegIds: new Uint32Array(gpuScratchBuffer, cap * 12, numVertices), }; } @@ -55,7 +53,6 @@ export interface SpatiallyIndexedSkeletonOverlayNodeLike { export interface SpatiallyIndexedSkeletonOverlayGeometry { positions: Float32Array; segmentIds: Uint32Array; - selected: Float32Array; nodeIds: Int32Array; pickSegmentIds: Uint32Array; pickEdgeSegmentIds: Uint32Array; @@ -66,11 +63,10 @@ export interface SpatiallyIndexedSkeletonOverlayGeometry { export function buildSpatiallyIndexedSkeletonOverlayGeometry( segmentNodeSets: readonly (readonly SpatiallyIndexedSkeletonOverlayNodeLike[])[], options: { - selectedNodeId?: number; getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; } = {}, ): SpatiallyIndexedSkeletonOverlayGeometry { - const { selectedNodeId, getPendingNodePosition } = options; + const { getPendingNodePosition } = options; const nodeIndex = new Map(); const orderedNodes: SpatiallyIndexedSkeletonOverlayNodeLike[] = []; @@ -94,7 +90,7 @@ export function buildSpatiallyIndexedSkeletonOverlayGeometry( // valid until SkeletonOverlayChunk uploads them to the GPU (synchronous), after // which this buffer is safe to reuse on the next build. const scratch = ensureGpuScratch(numVertices); - const { segmentIds, selected, edgeIndices, edgeSegIds } = scratch; + const { segmentIds, edgeIndices, edgeSegIds } = scratch; orderedNodes.forEach((node, index) => { const position = getPendingNodePosition?.(node.nodeId) ?? node.position; @@ -105,8 +101,6 @@ export function buildSpatiallyIndexedSkeletonOverlayGeometry( segmentIds[index] = Math.max(0, Math.round(Number(node.segmentId))); pickSegmentIds[index] = segmentIds[index]; nodeIds[index] = Math.round(Number(node.nodeId)); - selected[index] = - selectedNodeId !== undefined && node.nodeId === selectedNodeId ? 1 : 0; }); let edgeCount = 0; @@ -133,7 +127,6 @@ export function buildSpatiallyIndexedSkeletonOverlayGeometry( positions, // Subarray views into the scratch: consumed immediately by GPU upload. segmentIds: segmentIds.subarray(0, numVertices), - selected: selected.subarray(0, numVertices), nodeIds, pickSegmentIds, // Compact copy: CPU-retained by SkeletonOverlayChunk for edge picking. diff --git a/src/ui/command_palette.spec.ts b/src/ui/command_palette.spec.ts index af532113f..a067a03ec 100644 --- a/src/ui/command_palette.spec.ts +++ b/src/ui/command_palette.spec.ts @@ -38,7 +38,9 @@ describe("collectActionBindings", () => { const map = new EventActionMap(); map.set("keya", "some-action"); const bindings = collectActionBindings(makeViewer(map)); - expect(bindings.map((binding) => binding.actionId)).toContain("some-action"); + expect(bindings.map((binding) => binding.actionId)).toContain( + "some-action", + ); }); it("excludes mouse and wheel events", () => { diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 781cd7c0d..be24369f3 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -683,7 +683,7 @@ export class SpatialSkeletonEditTab extends Tab { try { getSegmentNavigationGraph(segmentId); return true; - } catch (error) { + } catch { StatusMessage.showTemporaryMessage(NAVIGATE_FROM_SPATIAL_INDEX_MESSAGE); return false; } diff --git a/src/webgl/texture_access.browser_test.ts b/src/webgl/texture_access.browser_test.ts index 2f3a4f616..0cb1030eb 100644 --- a/src/webgl/texture_access.browser_test.ts +++ b/src/webgl/texture_access.browser_test.ts @@ -22,7 +22,6 @@ import { OneDimensionalTextureAccessHelper, setOneDimensionalTextureData, TextureFormat, - updateOneDimensionalTextureElement, } from "#src/webgl/texture_access.js"; function testTextureAccess(dataLength: number) { @@ -100,157 +99,3 @@ describe("one_dimensional_texture_access", () => { test1dTextureAccess(128 * 128 * 128); }); }); - -describe("updateOneDimensionalTextureElement", () => { - test("updates a single element in a 1-D uint32 texture", () => { - const dataType = DataType.UINT32; - fragmentShaderTest( - { uOffset: "uint" }, - { outputValue: dataType }, - (tester) => { - const { gl, builder } = tester; - const numComponents = 1; - const format = new TextureFormat(); - computeTextureFormat(format, dataType, numComponents); - - const dataLength = 128; - const data = new Uint32Array(dataLength); - for (let i = 0; i < data.length; ++i) { - data[i] = i; - } - - const accessHelper = new OneDimensionalTextureAccessHelper( - "textureUpdate", - ); - const textureUnitSymbol = Symbol("textureUnit"); - accessHelper.defineShader(builder); - builder.addTextureSampler("usampler2D", "uSampler", textureUnitSymbol); - builder.addFragmentCode( - accessHelper.getAccessor( - "readValue", - "uSampler", - dataType, - numComponents, - ), - ); - builder.setFragmentMain(` -outputValue = readValue(uOffset); -`); - - tester.build(); - const { shader } = tester; - shader.bind(); - - const textureUnit = shader.textureUnit(textureUnitSymbol); - const texture = gl.createTexture()!; - tester.registerDisposer(() => { - gl.deleteTexture(texture); - }); - gl.bindTexture(gl.TEXTURE_2D, texture); - setOneDimensionalTextureData(gl, format, data); - gl.bindTexture(gl.TEXTURE_2D, null); - - const updatedIndex = 42; - const updatedValue = 99999; - const update = new Uint32Array([updatedValue]); - updateOneDimensionalTextureElement( - gl, - texture, - format, - dataLength, - updatedIndex, - update, - ); - - for (let i = 0; i < dataLength; ++i) { - gl.activeTexture(gl.TEXTURE0 + textureUnit); - gl.bindTexture(gl.TEXTURE_2D, texture); - tester.execute({ uOffset: i }); - gl.bindTexture(gl.TEXTURE_2D, null); - const expected = i === updatedIndex ? updatedValue : i; - expect(tester.values.outputValue, `offset=${i}`).toBe(expected); - } - }, - ); - }); - - test("out-of-bounds index is a no-op", () => { - const dataType = DataType.UINT32; - fragmentShaderTest( - { uOffset: "uint" }, - { outputValue: dataType }, - (tester) => { - const { gl, builder } = tester; - const numComponents = 1; - const format = new TextureFormat(); - computeTextureFormat(format, dataType, numComponents); - - const dataLength = 16; - const data = new Uint32Array(dataLength); - for (let i = 0; i < data.length; ++i) { - data[i] = i; - } - - const accessHelper = new OneDimensionalTextureAccessHelper( - "textureOob", - ); - const textureUnitSymbol = Symbol("textureUnit"); - accessHelper.defineShader(builder); - builder.addTextureSampler("usampler2D", "uSampler", textureUnitSymbol); - builder.addFragmentCode( - accessHelper.getAccessor( - "readValue", - "uSampler", - dataType, - numComponents, - ), - ); - builder.setFragmentMain(` -outputValue = readValue(uOffset); -`); - - tester.build(); - const { shader } = tester; - shader.bind(); - - const textureUnit = shader.textureUnit(textureUnitSymbol); - const texture = gl.createTexture()!; - tester.registerDisposer(() => { - gl.deleteTexture(texture); - }); - gl.bindTexture(gl.TEXTURE_2D, texture); - setOneDimensionalTextureData(gl, format, data); - gl.bindTexture(gl.TEXTURE_2D, null); - - const sentinel = new Uint32Array([99999]); - updateOneDimensionalTextureElement( - gl, - texture, - format, - dataLength, - -1, - sentinel, - ); - updateOneDimensionalTextureElement( - gl, - texture, - format, - dataLength, - dataLength, - sentinel, - ); - - for (let i = 0; i < dataLength; ++i) { - gl.activeTexture(gl.TEXTURE0 + textureUnit); - gl.bindTexture(gl.TEXTURE_2D, texture); - tester.execute({ uOffset: i }); - gl.bindTexture(gl.TEXTURE_2D, null); - expect( - tester.values.outputValue, - `element ${i} unchanged after oob update`, - ).toBe(i); - } - }, - ); - }); -}); diff --git a/src/webgl/texture_access.ts b/src/webgl/texture_access.ts index 471c41b1a..d3a58b5e5 100644 --- a/src/webgl/texture_access.ts +++ b/src/webgl/texture_access.ts @@ -347,50 +347,6 @@ export function setOneDimensionalTextureData( ); } -function getOneDimensionalTextureRowCapacity(gl: GL, numElements: number) { - const minX = Math.ceil(numElements / gl.maxTextureSize); - return 1 << Math.ceil(Math.log2(Math.max(minX, 1))); -} - -export function updateOneDimensionalTextureElement( - gl: GL, - texture: WebGLTexture, - format: TextureFormat, - numElements: number, - elementIndex: number, - data: TypedArray, -) { - if (elementIndex < 0 || elementIndex >= numElements) { - return; - } - const { arrayConstructor, texelsPerElement, textureFormat, texelType } = - format; - if (data.constructor !== arrayConstructor) { - data = new arrayConstructor( - data.buffer, - data.byteOffset, - data.byteLength / arrayConstructor.BYTES_PER_ELEMENT, - ); - } - const elementsPerRow = getOneDimensionalTextureRowCapacity(gl, numElements); - const x = (elementIndex % elementsPerRow) * texelsPerElement; - const y = Math.floor(elementIndex / elementsPerRow); - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, texture); - gl.pixelStorei(WebGL2RenderingContext.UNPACK_ALIGNMENT, 1); - gl.texSubImage2D( - WebGL2RenderingContext.TEXTURE_2D, - 0, - x, - y, - texelsPerElement, - 1, - textureFormat, - texelType, - data, - ); - gl.bindTexture(WebGL2RenderingContext.TEXTURE_2D, null); -} - export function setTwoDimensionalTextureData( gl: GL, format: TextureFormat,