diff --git a/docs/api-reference/experimental/lugraph.md b/docs/api-reference/experimental/lugraph.md index 26d3958955..1c864d3839 100644 --- a/docs/api-reference/experimental/lugraph.md +++ b/docs/api-reference/experimental/lugraph.md @@ -70,6 +70,39 @@ network, dependency map, fraud investigation, or other relationship visualizatio WebGPU-only educational example, not a large-graph performance benchmark: its exact layout costs `O(V² + E)` per force iteration and intentionally uses only 128 vertices. +### Use luGraph from deck.gl without copying graph buffers + +**Question: How can an existing deck.gl application explore a graph without converting GPU +relationships, analytics, or moving node positions into JavaScript objects?** + +The optional [luGraph + deck.gl network explorer](/examples/deck/lugraph-explorer) answers that +question with the reusable `LuGraphDeckEffect`, `LuGraphNodeLayer`, and `LuGraphEdgeLayer` +implementations from the existing private `@deck.gl-community/arrow-layers` adapter package, an +`OrthographicView`, and deck.gl's existing interaction and asynchronous WebGPU picking systems. +Use it when a social-network, service-dependency, fraud-investigation, or citation visualization +already uses deck.gl and needs GPU graph results to become directly drawable attributes. + +The effect first encodes forward and reverse adjacency, normalized PageRank, and weak components. +Later frames encode bounded neighborhood selection and exact force-directed layout into +deck.gl's own command encoder; deck.gl remains responsible for queue submission. The writable layout +allocation is also the node layer's `float32x2` instance vertex attribute. PageRank scores, +component labels, hop distances, and the selection mask remain GPU storage inputs; each nonempty +original edge partition gets its own edge layer, +without concatenation, buffer copies, or per-frame graph readback. + +The deterministic example fixture is uploaded once. Selecting, pinning, dragging, and changing +neighborhood depth write only the necessary interaction controls or coordinates; they do not +download graph columns. An explicitly requested native deck.gl pick returns the selected original +vertex identifier to JavaScript. Its implementation and transfer size belong to deck.gl; it is +separate from the native explorer's custom **8-byte** integer-picking path above. The example uses +exact `O(V² + E)` layout, not the optional spatial approximation, and does not promise large-graph +throughput. + +The reusable graph effect, node and edge layers, and graph integration's deck.gl imports live in +the existing private `@deck.gl-community/arrow-layers` adapter. The website-only example consumes +those exported symbols without importing `@deck.gl/core` or adding an example package; neither +`@luma.gl/experimental` nor its optional graph entry point depends on or imports deck.gl. + ## Measure real CPU and WebGPU graph workloads **Question: Does this graph workflow benefit from GPU execution on my actual browser, and what do diff --git a/examples/deck/lugraph-explorer/app.ts b/examples/deck/lugraph-explorer/app.ts new file mode 100644 index 0000000000..e59c283680 --- /dev/null +++ b/examples/deck/lugraph-explorer/app.ts @@ -0,0 +1,298 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import { + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer, + OrthographicView, + type PickingInfo +} from '@deck.gl-community/arrow-layers'; +import {Buffer, type Device} from '@luma.gl/core'; +import { + ShaderAssembler, + type GLSLShaderAssembler, + type WGSLShaderAssembler +} from '@luma.gl/shadertools'; +import {ArrowDeck} from '../arrow-deck'; +import {getDeckExampleProps, type DeckExampleDeviceOptions} from '../deck-example-device'; +import { + makeGraphExplorerDataset, + type GraphExplorerDataset +} from '../../experimental/lugraph-explorer/graph-data'; + +const DEFAULT_NEIGHBORHOOD_DEPTH = 2; + +type GraphExplorerControls = { + update: () => void; + destroy: () => void; +}; + +type LuGraphExplorerDeckOptions = DeckExampleDeviceOptions & { + dataset?: GraphExplorerDataset; +}; + +/** + * Creates an optional deck.gl explorer using resident luGraph analytics and original edge chunks. + * + * Deck owns the WebGPU frame encoder, rendering, controller, and asynchronous node picking. The + * graph module never depends on deck.gl, and no graph column is downloaded for animation, color, + * sizing, selection, or dragging. + */ +export function createLuGraphExplorerDeck( + parent?: HTMLDivElement, + options: LuGraphExplorerDeckOptions = {} +): ArrowDeck { + const {dataset, ...deviceOptions} = options; + const ownsContainer = !parent; + const container = parent ?? createStandaloneContainer(); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + + let effect: LuGraphDeckEffect | null = null; + let draggedVertex: number | null = null; + let restoreShaderAssembler: (() => void) | null = null; + let deck: ArrowDeck; + const controls = createExplorerControls(container, { + getEffect: () => effect, + redraw: reason => deck?.redraw(reason) + }); + + deck = new ArrowDeck({ + parent: container, + ...getDeckExampleProps({...deviceOptions, deviceType: 'webgpu'}), + views: new OrthographicView({id: 'lugraph-orthographic'}), + initialViewState: {target: [0, 0, 0], zoom: 7.7, minZoom: 5, maxZoom: 11}, + controller: { + dragPan: true, + scrollZoom: {smooth: true, speed: 0.02}, + doubleClickZoom: true, + touchZoom: true + }, + _animate: true, + pickAsync: 'auto', + layers: [], + effects: [], + onDeviceInitialized: initializedDevice => { + restoreShaderAssembler?.(); + restoreShaderAssembler = installLegacyDeckShaderAssemblerCompatibility(initializedDevice); + }, + onError: error => { + restoreShaderAssembler?.(); + restoreShaderAssembler = null; + throw error; + }, + getTooltip: info => getVertexTooltip(info, effect), + onClick: info => { + effect?.setSelectedVertex(info.picked && info.index >= 0 ? info.index : null); + controls.update(); + deck.redraw('luGraph deck selection changed'); + }, + onDragStart: (info, event) => { + if (!effect || !info.picked || info.index < 0) return; + draggedVertex = info.index; + effect.setSelectedVertex(draggedVertex); + effect.setPinnedVertex(draggedVertex, true); + updateDraggedVertex(effect, draggedVertex, info); + controls.update(); + event.stopPropagation(); + deck.redraw('luGraph vertex drag started'); + }, + onDrag: (info, event) => { + if (!effect || draggedVertex === null) return; + updateDraggedVertex(effect, draggedVertex, info); + event.stopPropagation(); + deck.redraw('luGraph vertex dragged'); + }, + onDragEnd: (_info, event) => { + if (draggedVertex === null) return; + draggedVertex = null; + controls.update(); + event.stopPropagation(); + deck.redraw('luGraph vertex pinned'); + }, + onLoad: ({deck: loadedDeck, device}) => { + if (device.type !== 'webgpu') throw new Error('luGraph deck explorer requires WebGPU'); + effect = new LuGraphDeckEffect(device, dataset ?? makeGraphExplorerDataset()); + const edgeLayers = effect.graph.sourceVertices.data.flatMap((source, chunkIndex) => { + if (source.length === 0) return []; + const target = effect!.graph.targetVertices.data[chunkIndex]; + return [ + new LuGraphEdgeLayer({ + id: `lugraph-edges-${chunkIndex}`, + data: [], + pickable: false, + positions: effect!.positions, + sourceVertices: source.buffer instanceof Buffer ? source.buffer : source.buffer.buffer, + targetVertices: target.buffer instanceof Buffer ? target.buffer : target.buffer.buffer, + distances: effect!.distances, + edgeCount: source.length, + opacity: 0.85 + }) + ]; + }); + const nodeLayer = new LuGraphNodeLayer({ + id: 'lugraph-nodes', + data: [], + pickable: true, + autoHighlight: true, + positions: effect.positions, + importance: effect.importance, + components: effect.componentLabels, + distances: effect.distances, + selectionMask: effect.selectionMask, + vertexCount: effect.graph.vertexCount, + opacity: 1 + }); + loadedDeck.setProps({effects: [effect], layers: [...edgeLayers, nodeLayer]}); + controls.update(); + loadedDeck.redraw('luGraph deck analytics initialized'); + }, + onFinalize: () => { + restoreShaderAssembler?.(); + restoreShaderAssembler = null; + draggedVertex = null; + controls.destroy(); + if (ownsContainer) container.remove(); + } + }); + + return deck; +} + +/** Bridges exactly one legacy Deck assembler call while preserving strict language separation. */ +function installLegacyDeckShaderAssemblerCompatibility(device: Device): () => void { + const original = ShaderAssembler.getDefaultShaderAssembler; + let restored = false; + + function restore(): void { + if (restored) return; + if (ShaderAssembler.getDefaultShaderAssembler === getLegacyDeckShaderAssembler) { + ShaderAssembler.getDefaultShaderAssembler = original; + } + restored = true; + } + + function getLegacyDeckShaderAssembler(shaderLanguage: 'glsl'): GLSLShaderAssembler; + function getLegacyDeckShaderAssembler(shaderLanguage: 'wgsl'): WGSLShaderAssembler; + function getLegacyDeckShaderAssembler( + shaderLanguage: 'glsl' | 'wgsl' + ): GLSLShaderAssembler | WGSLShaderAssembler; + function getLegacyDeckShaderAssembler( + shaderLanguage?: 'glsl' | 'wgsl' + ): GLSLShaderAssembler | WGSLShaderAssembler { + if (shaderLanguage === undefined) { + // TODO: Remove after deck.gl forwards its known shading language to luma.gl. + // Restore before forwarding so later user calls retain strict explicit-language behavior. + restore(); + return device.info.shadingLanguage === 'wgsl' + ? original.call(ShaderAssembler, 'wgsl') + : original.call(ShaderAssembler, 'glsl'); + } + return shaderLanguage === 'wgsl' + ? original.call(ShaderAssembler, 'wgsl') + : original.call(ShaderAssembler, 'glsl'); + } + + ShaderAssembler.getDefaultShaderAssembler = getLegacyDeckShaderAssembler; + return restore; +} + +/** Updates the same float32x2 allocation bound directly by the node layer's instance attribute. */ +function updateDraggedVertex(effect: LuGraphDeckEffect, vertex: number, info: PickingInfo): void { + const coordinate = info.coordinate; + if (!coordinate || coordinate.length < 2) return; + effect.setVertexPosition(vertex, [coordinate[0], coordinate[1]]); +} + +function getVertexTooltip(info: PickingInfo, effect: LuGraphDeckEffect | null): string | null { + if (!info.picked || info.index < 0 || !effect) return null; + const state = effect.isVertexPinned(info.index) ? 'pinned' : 'movable'; + return `Vertex ${info.index} · ${state}\nGPU PageRank sizing · component color`; +} + +/** Provides explicit selection/reset controls without polling or transferring graph metrics. */ +function createExplorerControls( + container: HTMLDivElement, + props: {getEffect: () => LuGraphDeckEffect | null; redraw: (reason: string) => void} +): GraphExplorerControls { + const panel = document.createElement('section'); + Object.assign(panel.style, { + position: 'absolute', + left: '14px', + top: '14px', + zIndex: '2', + width: '250px', + padding: '12px 14px', + borderRadius: '10px', + background: 'rgba(9, 15, 28, 0.86)', + border: '1px solid rgba(127, 173, 230, 0.2)', + color: '#eaf3ff', + font: '12px/1.5 system-ui, sans-serif' + }); + panel.innerHTML = ` + luGraph + deck.gl +

Resident graph analytics, source-chunk edge layers, + direct instance vertices, and real asynchronous deck.gl picking.

+ +
+ + +
+

Initializing WebGPU graph…

+

Click to inspect · drag to pin · scroll to zoom

`; + container.appendChild(panel); + + const depth = panel.querySelector('[data-lugraph-depth]'); + const reset = panel.querySelector('[data-lugraph-reset]'); + const release = panel.querySelector('[data-lugraph-release]'); + const status = panel.querySelector('[data-lugraph-status]'); + const update = (): void => { + const effect = props.getEffect(); + if (!effect || !status) return; + const selected = effect.currentSelection === null ? 'none' : `${effect.currentSelection}`; + status.textContent = `${effect.graph.vertexCount} vertices · ${effect.graph.edgeCount} chunked edges · selected ${selected}`; + }; + const updateDepth = (): void => { + props.getEffect()?.setNeighborhoodDepth(Number(depth?.value ?? DEFAULT_NEIGHBORHOOD_DEPTH)); + props.redraw('luGraph deck neighborhood depth changed'); + }; + const resetLayout = (): void => { + props.getEffect()?.requestReset(); + props.redraw('luGraph deck deterministic layout reset'); + }; + const clearPins = (): void => { + props.getEffect()?.clearPins(); + update(); + props.redraw('luGraph deck pins released'); + }; + depth?.addEventListener('input', updateDepth); + reset?.addEventListener('click', resetLayout); + release?.addEventListener('click', clearPins); + + return { + update, + destroy: () => { + depth?.removeEventListener('input', updateDepth); + reset?.removeEventListener('click', resetLayout); + release?.removeEventListener('click', clearPins); + panel.remove(); + } + }; +} + +function createStandaloneContainer(): HTMLDivElement { + document.body.style.margin = '0'; + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + inset: '0', + overflow: 'hidden', + background: '#070d18' + }); + document.body.appendChild(container); + return container; +} diff --git a/modules/arrow-layers/README.md b/modules/arrow-layers/README.md index 1205161f77..80f5006b7b 100644 --- a/modules/arrow-layers/README.md +++ b/modules/arrow-layers/README.md @@ -6,3 +6,33 @@ Private deck.gl layers backed by the Arrow adapters and `GPUVector` objects from The layers intentionally do not use deck.gl `AttributeManager` for Arrow columns. Arrow data is converted once into `GPUVector`/`GPUTable` inputs and bound directly to luma.gl models. + +## Graph effects and layers + +`LuGraphDeckEffect` composes topology, PageRank, weak components, neighborhood search, and +progressive force layout inside deck.gl's existing frame. Deck owns queue submission; the effect +retains original source and target edge partitions, including empty batches, without staging or +reading graph data back to the CPU. + +```ts +import { + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer, + type LuGraphDeckDataset +} from '@deck.gl-community/arrow-layers'; + +const dataset: LuGraphDeckDataset = { + vertexCount, + sourceChunks, + targetChunks, + positions, + velocities +}; +const effect = new LuGraphDeckEffect(device, dataset); +``` + +`LuGraphNodeLayer` consumes the exact progressive position allocation alongside resident PageRank, +component, distance, and selection outputs. Create one `LuGraphEdgeLayer` per nonempty original edge +partition to render caller-owned source and target buffers directly. The graph algorithms remain in +`@luma.gl/experimental/lugraph`; only this private adapter package depends on deck.gl. diff --git a/modules/arrow-layers/package.json b/modules/arrow-layers/package.json index c4106f11a1..54f45e5eb9 100644 --- a/modules/arrow-layers/package.json +++ b/modules/arrow-layers/package.json @@ -26,6 +26,7 @@ "@luma.gl/arrow": "9.4.0-alpha.4", "@luma.gl/core": "9.4.0-alpha.4", "@luma.gl/engine": "9.4.0-alpha.4", + "@luma.gl/experimental": "9.4.0-alpha.4", "@luma.gl/shadertools": "9.4.0-alpha.4", "@luma.gl/tables": "9.4.0-alpha.4", "apache-arrow": "^17.0.0" diff --git a/modules/arrow-layers/src/index.ts b/modules/arrow-layers/src/index.ts index 1c0a61fe2c..8751ec3956 100644 --- a/modules/arrow-layers/src/index.ts +++ b/modules/arrow-layers/src/index.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: Copyright (c) vis.gl contributors +export {OrthographicView, type PickingInfo} from '@deck.gl/core'; export { ArrowPolygonLayer, type ArrowPolygonColorInput, @@ -19,3 +20,14 @@ export { type ArrowTextLayerProps } from './layers/arrow-text-layer'; export type {ArrowLayerPickingInfo} from './layers/arrow-layer-types'; +export {LuGraphDeckEffect, type LuGraphDeckDataset} from './lugraph/lugraph-effect'; +export { + LUGRAPH_DECK_EDGE_SHADER, + LuGraphEdgeLayer, + type LuGraphEdgeLayerProps +} from './lugraph/lugraph-edge-layer'; +export { + LUGRAPH_DECK_NODE_SHADER, + LuGraphNodeLayer, + type LuGraphNodeLayerProps +} from './lugraph/lugraph-node-layer'; diff --git a/modules/arrow-layers/src/lugraph/lugraph-edge-layer.ts b/modules/arrow-layers/src/lugraph/lugraph-edge-layer.ts new file mode 100644 index 0000000000..f546214830 --- /dev/null +++ b/modules/arrow-layers/src/lugraph/lugraph-edge-layer.ts @@ -0,0 +1,149 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import {Layer, project32, type LayerContext, type LayerProps} from '@deck.gl/core'; +import {Buffer, type RenderPass} from '@luma.gl/core'; +import {Model} from '@luma.gl/engine'; + +/** One original aligned source/target GPU chunk and its shared resident graph state. */ +export type LuGraphEdgeLayerProps = LayerProps & { + positions: Buffer; + sourceVertices: Buffer; + targetVertices: Buffer; + distances: Buffer; + edgeCount: number; +}; + +type LuGraphEdgeLayerState = { + model: Model | null; + styleUniforms: Buffer | null; +}; + +const EDGE_BLEND_PARAMETERS = { + depthWriteEnabled: false, + blend: true, + blendColorOperation: 'add', + blendAlphaOperation: 'add', + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha' +} as const; + +/** Chunk-local source/target storage directly addresses the live shared layout positions. */ +export const LUGRAPH_DECK_EDGE_SHADER = /* wgsl */ ` +struct EdgeStyle { + opacity: f32, + _padding1: f32, + _padding2: f32, + _padding3: f32, +}; + +@group(0) @binding(auto) var positions: array>; +@group(0) @binding(auto) var sourceVertices: array; +@group(0) @binding(auto) var targetVertices: array; +@group(0) @binding(auto) var distances: array; +@group(0) @binding(auto) var edgeStyle: EdgeStyle; + +struct EdgeVertexOutput { + @builtin(position) position: vec4, + @location(0) color: vec4, +}; + +@vertex fn vertexMain( + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32 +) -> EdgeVertexOutput { + let sourceVertex = sourceVertices[instanceIndex]; + let targetVertex = targetVertices[instanceIndex]; + let vertex = select(sourceVertex, targetVertex, vertexIndex != 0u); + let position = positions[vertex]; + let sourceReached = distances[sourceVertex] != 0xffffffffu; + let targetReached = distances[targetVertex] != 0xffffffffu; + let isSelectedEdge = sourceReached && targetReached; + + geometry.worldPosition = vec3(position, 0.0); + var output: EdgeVertexOutput; + var clipPosition = project_position_to_clipspace( + vec3(position, 0.0), + vec3(0.0), + vec3(0.0) + ); + // Deck's OpenGL-style projection depth must be converted for WebGPU clipping. + clipPosition.z = (clipPosition.z + clipPosition.w) * 0.5; + output.position = clipPosition; + output.color = select( + vec4(0.32, 0.51, 0.70, 0.20), + vec4(0.95, 0.72, 0.32, 0.82), + isSelectedEdge + ); + return output; +} + +@fragment fn fragmentMain(input: EdgeVertexOutput) -> @location(0) vec4 { + return vec4(input.color.rgb, input.color.a * edgeStyle.opacity); +}`; + +/** Exactly one deck layer per nonempty original edge chunk; no implicit edge packing occurs. */ +export class LuGraphEdgeLayer extends Layer { + static override layerName = 'LuGraphEdgeLayer'; + static override defaultProps = {parameters: EDGE_BLEND_PARAMETERS}; + + override getAttributeManager() { + return null; + } + + /** Reports the original GPUData chunk population instead of the empty placeholder array. */ + override getNumInstances(): number { + return this.props.edgeCount; + } + + override initializeState({device}: LayerContext): void { + if (device.type !== 'webgpu') throw new Error('LuGraphEdgeLayer requires WebGPU'); + const styleUniforms = device.createBuffer({ + id: `${this.id}-style-uniforms`, + byteLength: 16, + usage: Buffer.UNIFORM | Buffer.COPY_DST + }); + const model = new Model(device, { + ...this.getShaders({modules: [project32], source: LUGRAPH_DECK_EDGE_SHADER}), + id: `${this.id}-model`, + topology: 'line-list', + isInstanced: true, + vertexCount: 2, + instanceCount: this.props.edgeCount, + bufferLayout: [], + bindings: { + positions: this.props.positions, + sourceVertices: this.props.sourceVertices, + targetVertices: this.props.targetVertices, + distances: this.props.distances, + edgeStyle: styleUniforms + }, + parameters: EDGE_BLEND_PARAMETERS + }); + this.setState({model, styleUniforms} satisfies LuGraphEdgeLayerState); + } + + override getModels(): Model[] { + const model = (this.state as LuGraphEdgeLayerState).model; + return model ? [model] : []; + } + + override draw({renderPass}: {renderPass: RenderPass}): void { + const {model, styleUniforms} = this.state as LuGraphEdgeLayerState; + if (!model || !styleUniforms) return; + styleUniforms.write(new Float32Array([this.props.opacity ?? 1, 0, 0, 0])); + model.setInstanceCount(this.props.edgeCount); + model.draw(renderPass); + } + + override finalizeState(context: LayerContext): void { + const state = this.state as LuGraphEdgeLayerState; + state.model?.destroy(); + state.styleUniforms?.destroy(); + this.setState({model: null, styleUniforms: null} satisfies LuGraphEdgeLayerState); + super.finalizeState(context); + } +} diff --git a/modules/arrow-layers/src/lugraph/lugraph-effect.ts b/modules/arrow-layers/src/lugraph/lugraph-effect.ts new file mode 100644 index 0000000000..8edd98ae99 --- /dev/null +++ b/modules/arrow-layers/src/lugraph/lugraph-effect.ts @@ -0,0 +1,352 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import type {Effect, EffectContext} from '@deck.gl/core'; +import {Buffer, type Device} from '@luma.gl/core'; +import {GPUCommandGraph, type CompiledGPUCommandGraph} from '@luma.gl/experimental'; +import { + LuGraph, + LuGraphBreadthFirstSearch, + LuGraphConnectedComponents, + LuGraphForceLayout, + LuGraphPageRank, + LuGraphTopology, + type LuGraphAdjacency +} from '@luma.gl/experimental/lugraph'; +import {GPUData, GPUVector} from '@luma.gl/tables'; + +/** Caller-owned graph input preserving original edge partitions and vertex allocations. */ +export type LuGraphDeckDataset = { + /** Number of stable zero-based graph vertices. */ + vertexCount: number; + /** Original directed source-edge batches, including any empty partitions. */ + sourceChunks: Uint32Array[]; + /** Original target-edge batches aligned with the source partitions. */ + targetChunks: Uint32Array[]; + /** Initial directly renderable two-component positions in source-vertex order. */ + positions: Float32Array; + /** Initial progressive two-component velocities in source-vertex order. */ + velocities: Float32Array; +}; + +const SCALAR_BYTE_LENGTH = 4; +const MAXIMUM_NEIGHBORHOOD_DEPTH = 8; +const DEFAULT_NEIGHBORHOOD_DEPTH = 2; + +type ScalarFormat = 'uint32' | 'float32'; + +/** + * Declares resident luGraph analytics and progressive layout inside deck.gl's own render encoder. + * + * Construction compiles persistent graphs but never submits commands or reads a buffer. Original + * source edge batches, including their empty middle partition, remain directly available to Deck + * edge layers. The first ordinary frame encodes topology, PageRank, and weak components once; + * every ordinary frame then encodes bounded neighborhood search and force integration. + */ +export class LuGraphDeckEffect implements Effect { + readonly id = 'lugraph-deck-effect'; + readonly props = {}; + readonly useInPicking = false; + readonly device: Device; + readonly dataset: LuGraphDeckDataset; + readonly graph: LuGraph; + readonly topology: LuGraphTopology; + readonly pageRank: LuGraphPageRank; + readonly components: LuGraphConnectedComponents; + readonly search: LuGraphBreadthFirstSearch; + readonly layout: LuGraphForceLayout; + readonly analysisGraph: CompiledGPUCommandGraph; + readonly frameGraph: CompiledGPUCommandGraph; + + private readonly buffers: Buffer[] = []; + private readonly vectors: GPUVector[] = []; + private readonly seeds: GPUVector<'uint32'>; + private readonly seedCount: GPUVector<'uint32'>; + private readonly activeDepth: GPUVector<'uint32'>; + private readonly pinned: GPUVector<'uint32'>; + private readonly reset: GPUVector<'uint32'>; + private readonly pinnedVertices = new Set(); + private selectedVertex: number | null = 0; + private neighborhoodDepth = DEFAULT_NEIGHBORHOOD_DEPTH; + private analyticsPending = true; + private destroyed = false; + + constructor(device: Device, dataset: LuGraphDeckDataset) { + if (device.type !== 'webgpu') throw new Error('LuGraphDeckEffect requires WebGPU'); + this.device = device; + this.dataset = dataset; + + this.graph = new LuGraph({ + vertexCount: dataset.vertexCount, + directed: true, + sourceVertices: this.createChunkedVector('source-vertices', dataset.sourceChunks), + targetVertices: this.createChunkedVector('target-vertices', dataset.targetChunks) + }); + this.topology = new LuGraphTopology({ + id: 'lugraph-deck-topology', + graph: this.graph, + forward: this.createAdjacency('forward', dataset.vertexCount, this.graph.edgeCount), + reverse: this.createAdjacency('reverse', dataset.vertexCount, this.graph.edgeCount), + invalidEdgeCount: this.createScalarVector('invalid-edges', 'uint32', 1) + }); + this.pageRank = new LuGraphPageRank({ + id: 'lugraph-deck-page-rank', + topology: this.topology, + output: this.createScalarVector('importance', 'float32', dataset.vertexCount), + iterations: 12 + }); + this.components = new LuGraphConnectedComponents({ + id: 'lugraph-deck-components', + topology: this.topology, + output: this.createScalarVector('components', 'uint32', dataset.vertexCount), + iterations: 16 + }); + + this.seeds = this.createScalarVector('seeds', 'uint32', 1, Uint32Array.of(0)); + this.seedCount = this.createScalarVector('seed-count', 'uint32', 1, Uint32Array.of(1)); + this.activeDepth = this.createScalarVector( + 'active-depth', + 'uint32', + 1, + Uint32Array.of(DEFAULT_NEIGHBORHOOD_DEPTH) + ); + this.search = new LuGraphBreadthFirstSearch({ + id: 'lugraph-deck-neighborhood', + topology: this.topology, + seeds: this.seeds, + seedCount: this.seedCount, + distances: this.createScalarVector('distances', 'uint32', dataset.vertexCount), + predecessors: this.createScalarVector('predecessors', 'uint32', dataset.vertexCount), + mask: this.createScalarVector('selection-mask', 'uint32', dataset.vertexCount), + maxDepth: MAXIMUM_NEIGHBORHOOD_DEPTH, + activeDepth: this.activeDepth, + direction: 'both' + }); + + this.pinned = this.createScalarVector('pinned', 'uint32', dataset.vertexCount); + this.reset = this.createScalarVector('reset', 'uint32', 1, Uint32Array.of(0)); + this.layout = new LuGraphForceLayout({ + id: 'lugraph-deck-force-layout', + topology: this.topology, + positions: this.createCoordinateVector('positions', dataset.positions, Buffer.VERTEX), + velocities: this.createCoordinateVector('velocities', dataset.velocities), + pinned: this.pinned, + reset: this.reset, + seed: 0x1a2b3c4d, + iterationsPerFrame: 2, + repulsion: 0.005, + attraction: 0.045, + gravity: 0.025, + damping: 0.85, + maxVelocity: 0.045 + }); + + const analysis = new GPUCommandGraph(device, {id: 'lugraph-deck-analysis'}); + this.topology.addToGraph(analysis); + this.components.addToGraph(analysis); + this.pageRank.addToGraph(analysis); + this.analysisGraph = analysis.compile(); + + const frame = new GPUCommandGraph(device, {id: 'lugraph-deck-frame'}); + this.search.addToGraph(frame); + this.layout.addToGraph(frame); + this.frameGraph = frame.compile(); + } + + /** Supplies the exact progressive allocation also bound as a Deck instance vertex attribute. */ + get positions(): Buffer { + return this.getVectorBuffer(this.layout.positions); + } + + get importance(): Buffer { + return this.getVectorBuffer(this.pageRank.output); + } + + get componentLabels(): Buffer { + return this.getVectorBuffer(this.components.output); + } + + get distances(): Buffer { + return this.getVectorBuffer(this.search.distances); + } + + get selectionMask(): Buffer { + return this.getVectorBuffer(this.search.mask!); + } + + get currentSelection(): number | null { + return this.selectedVertex; + } + + get currentNeighborhoodDepth(): number { + return this.neighborhoodDepth; + } + + setup(_context: EffectContext): void {} + + /** Appends compute to Deck's current frame; Deck remains the sole queue submission owner. */ + preRender(options: Parameters[0]): void { + if (this.destroyed || !options.viewports[0]) return; + if (this.analyticsPending) { + this.analysisGraph.encode(this.device.commandEncoder, {parameters: undefined}); + this.analyticsPending = false; + } + this.frameGraph.encode(this.device.commandEncoder, {parameters: undefined}); + } + + /** Publishes a genuinely picked stable source vertex without reading any graph column. */ + setSelectedVertex(vertex: number | null): void { + if (vertex !== null && !this.isValidVertex(vertex)) return; + this.selectedVertex = vertex; + if (vertex === null) { + this.getVectorBuffer(this.seedCount).write(Uint32Array.of(0)); + } else { + this.getVectorBuffer(this.seeds).write(Uint32Array.of(vertex)); + this.getVectorBuffer(this.seedCount).write(Uint32Array.of(1)); + } + } + + /** Updates the existing GPU-resident dynamic hop limit without recompiling traversal passes. */ + setNeighborhoodDepth(depth: number): void { + this.neighborhoodDepth = Math.max(0, Math.min(MAXIMUM_NEIGHBORHOOD_DEPTH, Math.round(depth))); + this.getVectorBuffer(this.activeDepth).write(Uint32Array.of(this.neighborhoodDepth)); + } + + /** Pins or releases exactly one original source vertex; no other rows are repacked. */ + setPinnedVertex(vertex: number, pinned: boolean): void { + if (!this.isValidVertex(vertex)) return; + this.getVectorBuffer(this.pinned).write( + Uint32Array.of(pinned ? 1 : 0), + vertex * SCALAR_BYTE_LENGTH + ); + if (pinned) this.pinnedVertices.add(vertex); + else this.pinnedVertices.delete(vertex); + } + + isVertexPinned(vertex: number): boolean { + return this.pinnedVertices.has(vertex); + } + + /** Moves the same physical vertex allocation consumed by the active Deck node model. */ + setVertexPosition(vertex: number, position: readonly [number, number]): void { + if (!this.isValidVertex(vertex) || !position.every(Number.isFinite)) return; + this.positions.write(Float32Array.from(position), vertex * 2 * SCALAR_BYTE_LENGTH); + this.getVectorBuffer(this.layout.velocities).write( + Float32Array.of(0, 0), + vertex * 2 * SCALAR_BYTE_LENGTH + ); + } + + clearPins(): void { + this.getVectorBuffer(this.pinned).write(new Uint32Array(this.graph.vertexCount)); + this.pinnedVertices.clear(); + } + + requestReset(): void { + this.getVectorBuffer(this.reset).write(Uint32Array.of(1)); + } + + /** Releases only effect-owned graphs and buffers; aggregate vectors remain borrowing views. */ + cleanup(_context: EffectContext): void { + if (this.destroyed) return; + this.destroyed = true; + this.analysisGraph.destroy(); + this.frameGraph.destroy(); + for (const vector of this.vectors.reverse()) vector.destroy(); + for (const buffer of this.buffers.reverse()) buffer.destroy(); + } + + private isValidVertex(vertex: number): boolean { + return Number.isSafeInteger(vertex) && vertex >= 0 && vertex < this.graph.vertexCount; + } + + /** Preserves all original aligned GPUData partitions, including zero-length source batches. */ + private createChunkedVector(name: string, chunks: Uint32Array[]): GPUVector<'uint32'> { + const data = chunks.map((values, chunkIndex) => { + const buffer = this.device.createBuffer({ + id: `lugraph-deck-${name}-${chunkIndex}`, + data: values.length === 0 ? new Uint32Array(1) : values, + usage: Buffer.STORAGE | Buffer.COPY_DST + }); + this.buffers.push(buffer); + return new GPUData<'uint32'>({ + buffer, + format: 'uint32', + length: values.length, + ownsBuffer: false + }); + }); + const vector = new GPUVector<'uint32'>({ + type: 'data', + name, + format: 'uint32', + data, + ownsData: false + }); + this.vectors.push(vector); + return vector; + } + + private createScalarVector( + name: string, + format: Format, + length: number, + values?: Uint32Array | Float32Array + ): GPUVector { + const buffer = this.device.createBuffer({ + id: `lugraph-deck-${name}`, + byteLength: Math.max(length, 1) * SCALAR_BYTE_LENGTH, + usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST + }); + if (values?.length) buffer.write(values); + this.buffers.push(buffer); + const vector = new GPUVector({ + type: 'buffer', + name, + format, + buffer, + length, + ownsBuffer: false + }); + this.vectors.push(vector); + return vector; + } + + private createCoordinateVector( + name: string, + values: Float32Array, + additionalUsage = 0 + ): GPUVector<'float32x2'> { + const buffer = this.device.createBuffer({ + id: `lugraph-deck-${name}`, + data: values, + usage: Buffer.STORAGE | Buffer.COPY_DST | Buffer.COPY_SRC | additionalUsage + }); + this.buffers.push(buffer); + const vector = new GPUVector<'float32x2'>({ + type: 'buffer', + name, + format: 'float32x2', + buffer, + length: values.length / 2, + ownsBuffer: false + }); + this.vectors.push(vector); + return vector; + } + + private createAdjacency(name: string, vertexCount: number, capacity: number): LuGraphAdjacency { + return { + offsets: this.createScalarVector(`${name}-offsets`, 'uint32', vertexCount + 1), + neighbors: this.createScalarVector(`${name}-neighbors`, 'uint32', capacity), + edgeIds: this.createScalarVector(`${name}-edge-ids`, 'uint32', capacity), + count: this.createScalarVector(`${name}-count`, 'uint32', 1), + overflow: this.createScalarVector(`${name}-overflow`, 'uint32', 1) + }; + } + + private getVectorBuffer(vector: GPUVector): Buffer { + return vector.data[0].buffer as Buffer; + } +} diff --git a/modules/arrow-layers/src/lugraph/lugraph-node-layer.ts b/modules/arrow-layers/src/lugraph/lugraph-node-layer.ts new file mode 100644 index 0000000000..9a5f0ed6d7 --- /dev/null +++ b/modules/arrow-layers/src/lugraph/lugraph-node-layer.ts @@ -0,0 +1,226 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import { + Layer, + picking, + project32, + type LayerContext, + type LayerProps, + type PickingInfo +} from '@deck.gl/core'; +import {Buffer, type RenderPass} from '@luma.gl/core'; +import {Model} from '@luma.gl/engine'; + +/** Actual luGraph allocations consumed by a deck.gl node layer without staging or copying. */ +export type LuGraphNodeLayerProps = LayerProps & { + positions: Buffer; + importance: Buffer; + components: Buffer; + distances: Buffer; + selectionMask: Buffer; + vertexCount: number; +}; + +type LuGraphNodeLayerState = { + model: Model | null; + styleUniforms: Buffer | null; +}; + +const NODE_BLEND_PARAMETERS = { + depthWriteEnabled: false, + blend: true, + blendColorOperation: 'add', + blendAlphaOperation: 'add', + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha' +} as const; + +/** Direct instance vertex fetch and four independently computed resident graph attributes. */ +export const LUGRAPH_DECK_NODE_SHADER = /* wgsl */ ` +struct NodeStyle { + radiusPixels: f32, + opacity: f32, + pickingActive: f32, + vertexCount: f32, +}; + +@group(0) @binding(auto) var importance: array; +@group(0) @binding(auto) var components: array; +@group(0) @binding(auto) var distances: array; +@group(0) @binding(auto) var selectionMask: array; +@group(0) @binding(auto) var nodeStyle: NodeStyle; + +struct NodeVertexOutput { + @builtin(position) position: vec4, + @location(0) corner: vec2, + @location(1) color: vec4, + @location(2) @interpolate(flat) pickingColor: vec3, +}; + +fn getNodeCorner(vertexIndex: u32) -> vec2 { + let corners = array, 6>( + vec2(-1.0, -1.0), vec2(1.0, -1.0), vec2(-1.0, 1.0), + vec2(-1.0, 1.0), vec2(1.0, -1.0), vec2(1.0, 1.0) + ); + return corners[vertexIndex]; +} + +fn getComponentColor(component: u32) -> vec3 { + let colors = array, 6>( + vec3(0.26, 0.79, 1.00), + vec3(1.00, 0.58, 0.28), + vec3(0.74, 0.48, 1.00), + vec3(0.34, 0.92, 0.66), + vec3(1.00, 0.41, 0.66), + vec3(0.96, 0.86, 0.34) + ); + return colors[component % 6u]; +} + +fn encodeNodePickingColor(vertex: u32) -> vec3 { + let index = vertex + 1u; + return vec3( + f32(index % 256u), + f32((index / 256u) % 256u), + f32((index / 65536u) % 256u) + ) / 255.0; +} + +@vertex fn vertexMain( + @location(0) nodePosition: vec2, + @builtin(vertex_index) vertexIndex: u32, + @builtin(instance_index) instanceIndex: u32 +) -> NodeVertexOutput { + let corner = getNodeCorner(vertexIndex); + let pickingColor = encodeNodePickingColor(instanceIndex); + let selected = selectionMask[instanceIndex] != 0u; + let reached = distances[instanceIndex] != 0xffffffffu; + let rank = max(importance[instanceIndex] * nodeStyle.vertexCount, 0.0); + var radius = nodeStyle.radiusPixels * clamp(sqrt(rank), 0.65, 2.3); + + let highlightedColor = picking_normalizeColor(picking.highlightedObjectColor); + let highlighted = picking.isHighlightActive > 0.5 && + distance(pickingColor, highlightedColor) < 0.00001; + if (selected || highlighted) { radius *= 1.35; } + + geometry.worldPosition = vec3(nodePosition, 0.0); + geometry.pickingColor = pickingColor; + var clipPosition = project_position_to_clipspace( + vec3(nodePosition, 0.0), + vec3(0.0), + vec3(0.0) + ); + // Deck's project32 matrices use OpenGL depth; WebGPU clip space requires [0, w]. + clipPosition.z = (clipPosition.z + clipPosition.w) * 0.5; + clipPosition = vec4( + clipPosition.xy + project_pixel_size_to_clipspace(corner * radius), + clipPosition.z, + clipPosition.w + ); + + let componentColor = getComponentColor(components[instanceIndex]); + let accentColor = select(componentColor, vec3(1.0, 0.78, 0.28), selected); + let brightness = select(0.56, 1.0, reached || selected || highlighted); + + var output: NodeVertexOutput; + output.position = clipPosition; + output.corner = corner; + output.color = vec4(accentColor * brightness, 0.95); + output.pickingColor = pickingColor; + return output; +} + +@fragment fn fragmentMain(input: NodeVertexOutput) -> @location(0) vec4 { + let radiusSquared = dot(input.corner, input.corner); + if (radiusSquared > 1.0) { discard; } + if (nodeStyle.pickingActive > 0.5) { + return vec4(input.pickingColor, 1.0); + } + let coverage = 1.0 - smoothstep(0.48, 1.0, radiusSquared); + return vec4(input.color.rgb, input.color.a * nodeStyle.opacity * coverage); +}`; + +/** Deck layer whose actual instance vertex attribute is the progressive luGraph position buffer. */ +export class LuGraphNodeLayer extends Layer { + static override layerName = 'LuGraphNodeLayer'; + static override defaultProps = {parameters: NODE_BLEND_PARAMETERS}; + + override getAttributeManager() { + return null; + } + + /** Keeps Deck picking and lifecycle counts aligned with the resident vertex allocation. */ + override getNumInstances(): number { + return this.props.vertexCount; + } + + override initializeState({device}: LayerContext): void { + if (device.type !== 'webgpu') throw new Error('LuGraphNodeLayer requires WebGPU'); + const styleUniforms = device.createBuffer({ + id: `${this.id}-style-uniforms`, + byteLength: 16, + usage: Buffer.UNIFORM | Buffer.COPY_DST + }); + const model = new Model(device, { + ...this.getShaders({modules: [project32, picking], source: LUGRAPH_DECK_NODE_SHADER}), + id: `${this.id}-model`, + topology: 'triangle-list', + isInstanced: true, + vertexCount: 6, + instanceCount: this.props.vertexCount, + attributes: {nodePosition: this.props.positions}, + bufferLayout: [{name: 'nodePosition', format: 'float32x2', stepMode: 'instance'}], + bindings: { + importance: this.props.importance, + components: this.props.components, + distances: this.props.distances, + selectionMask: this.props.selectionMask, + nodeStyle: styleUniforms + }, + parameters: NODE_BLEND_PARAMETERS + }); + this.setState({model, styleUniforms} satisfies LuGraphNodeLayerState); + } + + override getModels(): Model[] { + const model = (this.state as LuGraphNodeLayerState).model; + return model ? [model] : []; + } + + override draw({ + renderPass, + shaderModuleProps + }: { + renderPass: RenderPass; + shaderModuleProps?: {picking?: {isActive?: number | boolean}}; + }): void { + const {model, styleUniforms} = this.state as LuGraphNodeLayerState; + if (!model || !styleUniforms) return; + styleUniforms.write( + new Float32Array([ + 6, + this.props.opacity ?? 1, + shaderModuleProps?.picking?.isActive ? 1 : 0, + this.props.vertexCount + ]) + ); + model.setInstanceCount(this.props.vertexCount); + model.draw(renderPass); + } + + override getPickingInfo({info}: {info: PickingInfo}): PickingInfo { + return info; + } + + override finalizeState(context: LayerContext): void { + const state = this.state as LuGraphNodeLayerState; + state.model?.destroy(); + state.styleUniforms?.destroy(); + this.setState({model: null, styleUniforms: null} satisfies LuGraphNodeLayerState); + super.finalizeState(context); + } +} diff --git a/modules/arrow-layers/test/lu-graph-deck.node.spec.ts b/modules/arrow-layers/test/lu-graph-deck.node.spec.ts new file mode 100644 index 0000000000..1ac52b8575 --- /dev/null +++ b/modules/arrow-layers/test/lu-graph-deck.node.spec.ts @@ -0,0 +1,260 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import {existsSync, readFileSync} from 'node:fs'; + +import { + LUGRAPH_DECK_EDGE_SHADER, + LUGRAPH_DECK_NODE_SHADER, + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer +} from '@deck.gl-community/arrow-layers'; +import * as experimentalModule from '@luma.gl/experimental'; +import * as luGraphModule from '@luma.gl/experimental/lugraph'; +import {describe, expect, test} from 'vitest'; + +import {createLuGraphExplorerDeck} from '../../../examples/deck/lugraph-explorer/app'; +import {makeGraphExplorerDataset} from '../../../examples/experimental/lugraph-explorer/graph-data'; +import {getExampleThumbnailPath} from '../../../website/src/example-thumbnails'; + +type ExampleContentsEntry = { + type: string; + label?: string; + id?: string; + items?: Array; +}; + +describe('optional luGraph deck.gl integration package isolation', () => { + test('keeps deck.gl entirely outside graph production imports and dependencies', () => { + const packageJson = JSON.parse( + readFileSync(new URL('../../experimental/package.json', import.meta.url), 'utf8') + ) as { + dependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + }; + + for (const dependencies of [ + packageJson.dependencies, + packageJson.peerDependencies, + packageJson.optionalDependencies + ]) { + expect(Object.keys(dependencies ?? {}).some(name => name.startsWith('@deck.gl/'))).toBe( + false + ); + } + expect('LuGraphDeckEffect' in experimentalModule).toBe(false); + expect('LuGraphDeckEffect' in luGraphModule).toBe(false); + expect('LuGraphNodeLayer' in luGraphModule).toBe(false); + expect('LuGraphEdgeLayer' in luGraphModule).toBe(false); + }); + + test('keeps deck.gl and GPU graph dependencies inside the existing private layers package', () => { + const packageJson = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) as {private?: boolean; dependencies?: Record}; + + expect(packageJson.private).toBe(true); + expect(packageJson.dependencies?.['@deck.gl/core']).toBe('9.3.4'); + expect(packageJson.dependencies?.['@luma.gl/experimental']).toBe('9.4.0-alpha.4'); + expect(packageJson.dependencies?.['@luma.gl/tables']).toBe('9.4.0-alpha.4'); + }); + + test('loads deck.gl graph adapters through the private package boundary', () => { + const exampleSource = readFileSync( + new URL('../../../examples/deck/lugraph-explorer/app.ts', import.meta.url), + 'utf8' + ); + + expect(exampleSource).toContain("from '@deck.gl-community/arrow-layers'"); + expect(exampleSource).not.toContain('@deck.gl-community/luspatial'); + expect(exampleSource).not.toContain('@deck.gl/core'); + }); + + test('does not create an example workspace, package manifest, or integration dependency', () => { + expect( + existsSync(new URL('../../../examples/deck/lugraph-explorer/package.json', import.meta.url)) + ).toBe(false); + expect(typeof createLuGraphExplorerDeck).toBe('function'); + expect(typeof LuGraphDeckEffect).toBe('function'); + }); +}); + +describe('luGraph native deck.gl resident layers', () => { + test('exposes actual deck.gl node and original-partition edge layer classes', () => { + expect(LuGraphNodeLayer.layerName).toBe('LuGraphNodeLayer'); + expect(LuGraphEdgeLayer.layerName).toBe('LuGraphEdgeLayer'); + + const dataset = makeGraphExplorerDataset(); + expect(dataset.sourceChunks.map(chunk => chunk.length)).toEqual( + dataset.targetChunks.map(chunk => chunk.length) + ); + expect(dataset.sourceChunks).toHaveLength(3); + expect(dataset.sourceChunks[1]).toHaveLength(0); + expect(dataset.sourceChunks.filter(chunk => chunk.length > 0)).toHaveLength(2); + }); + + test('fetches actual position vertices, GPU analytics, and stable original picking identifiers', () => { + expect(LUGRAPH_DECK_NODE_SHADER).toContain('@location(0) nodePosition: vec2'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('importance: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('components: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('distances: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('selectionMask: array'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('vertex + 1u'); + expect(LUGRAPH_DECK_NODE_SHADER).toContain('geometry.pickingColor'); + expect(LUGRAPH_DECK_NODE_SHADER).not.toMatch(/atomic\s*<\s*f32\s*>/); + }); + + test('reads source and target edge chunks directly without concatenation or CPU staging', () => { + expect(LUGRAPH_DECK_EDGE_SHADER).toContain('sourceVertices: array'); + expect(LUGRAPH_DECK_EDGE_SHADER).toContain('targetVertices: array'); + expect(LUGRAPH_DECK_EDGE_SHADER).toContain('positions: array>'); + + const effectSource = readFileSync( + new URL('../src/lugraph/lugraph-effect.ts', import.meta.url), + 'utf8' + ); + expect(effectSource).toContain('this.device.commandEncoder'); + expect(effectSource).not.toMatch(/\bdevice\.submit\s*\(/); + expect(effectSource).not.toMatch(/\.readAsync\s*\(/); + }); +}); + +describe('optional luGraph deck.gl gallery and API guide', () => { + test('registers lazy loading and a WebGPU-only GPGPU gallery destination', () => { + const examples = readFileSync( + new URL('../../../website/src/examples.tsx', import.meta.url), + 'utf8' + ); + const contents = JSON.parse( + readFileSync( + new URL('../../../website/content/examples/table-of-contents.json', import.meta.url), + 'utf8' + ) + ) as ExampleContentsEntry[]; + const gpuCategory = contents.find(category => category.label === 'GPGPU'); + const graphModulesCategory = gpuCategory?.items?.find( + (item): item is ExampleContentsEntry => + typeof item !== 'string' && item.label === 'GPGPU Graph Modules' + ); + + expect(examples).toContain( + "const loadLuGraphExplorerDeckExample = () => import('../../examples/deck/lugraph-explorer/app')" + ); + expect(examples).toContain('useDeferredExampleModule(loadLuGraphExplorerDeckExample)'); + expect(examples).toContain('createDeck: module.createLuGraphExplorerDeck'); + expect(examples).not.toMatch(/^import\s+\{createLuGraphExplorerDeck\}\s+from/m); + expect( + graphModulesCategory?.items?.some( + item => typeof item !== 'string' && item.id === 'deck/lugraph-explorer' + ) + ).toBe(true); + }); + + test('provides curated WebGPU metadata, an existing network thumbnail, and optional API guidance', () => { + const examplePage = readFileSync( + new URL('../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + 'utf8' + ); + const apiGuide = readFileSync( + new URL('../../../docs/api-reference/experimental/lugraph.md', import.meta.url), + 'utf8' + ); + const topics = examplePage + .match(/topics:\s*\[([^\]]+)\]/)?.[1] + .split(',') + .map(topic => topic.trim()); + + expect(examplePage).toContain('backends: [webgpu]'); + expect(examplePage).toContain(''); + expect(topics?.length).toBeGreaterThanOrEqual(2); + expect(topics?.length).toBeLessThanOrEqual(5); + expect(new Set(topics).size).toBe(topics?.length); + expect(getExampleThumbnailPath('deck/lugraph-explorer')).toBe('showcase/packet-spraying.jpg'); + expect(apiGuide).toContain('/examples/deck/lugraph-explorer'); + expect(apiGuide).toContain("deck.gl's own command"); + expect(apiGuide).toContain('without concatenation, buffer copies, or per-frame graph readback'); + }); + + test('explains when deck.gl graph integration is useful and how to explore it', () => { + const examplePage = readFileSync( + new URL('../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + 'utf8' + ); + + for (const section of [ + '## Overview', + '## Why combine luGraph and deck.gl?', + '## When should I use this integration?', + '## How the GPU-resident frame works', + '## Try the controls', + '## What actually stays on the GPU', + '## Boundaries and performance' + ]) { + expect(examplePage, section).toContain(section); + } + + for (const useCase of [ + 'Social and communication networks', + 'Service and package dependencies', + 'Transaction and fraud investigations', + 'Knowledge and citation maps' + ]) { + expect(examplePage, useCase).toContain(useCase); + } + + for (const control of [ + '**Hover a node**', + '**Click a node**', + '**Adjust neighborhood depth**', + '**Drag a node**', + '**Release pins**', + '**Reset layout**', + '**Pan and zoom**' + ]) { + expect(examplePage, control).toContain(control); + } + + expect(examplePage).toContain('128-vertex'); + expect(examplePage).toContain('`O(V² + E)`'); + expect(examplePage).toContain('not a large-graph'); + expect(examplePage).toContain('does not enable the optional spatial approximation'); + expect(examplePage).toContain('/docs/api-reference/experimental/lugraph'); + expect(examplePage).toContain('/examples/experimental/lugraph-explorer'); + }); + + test('documents direct GPU frame ownership and honest asynchronous deck.gl picking', () => { + const examplePage = readFileSync( + new URL('../../../website/content/examples/deck/lugraph-explorer.mdx', import.meta.url), + 'utf8' + ); + const apiGuide = readFileSync( + new URL('../../../docs/api-reference/experimental/lugraph.md', import.meta.url), + 'utf8' + ); + + expect(examplePage).toContain('Upload the demonstration fixture once'); + expect(examplePage).toContain('intentionally empty batch'); + expect(examplePage).toContain('LuGraphDeckEffect'); + expect(examplePage).toContain('LuGraphNodeLayer'); + expect(examplePage).toContain('LuGraphEdgeLayer'); + expect(examplePage).toContain('not\n community-detection results'); + expect(examplePage).toContain('deck.gl owns queue'); + expect(examplePage).toContain('`Buffer.STORAGE` and `Buffer.VERTEX`'); + expect(examplePage).toContain('returns a requested selected-vertex result to JavaScript'); + expect(examplePage).toContain('`PickingInfo.index`'); + expect(examplePage).toContain('deck.gl owns that picking implementation and its transfer size'); + + expect(apiGuide).toContain('## Overview'); + expect(apiGuide).toContain('## Why keep a graph on the GPU?'); + expect(apiGuide).toContain('## When should I use luGraph?'); + expect(apiGuide).toContain(''); + expect(apiGuide).toContain(''); + expect(apiGuide).toContain('## Approximate distant forces with LuGraphSpatialForceLayout'); + expect(apiGuide).toContain('### Use luGraph from deck.gl without copying graph buffers'); + expect(apiGuide).toContain("separate from the native explorer's custom **8-byte**"); + expect(apiGuide).toContain('The deterministic example fixture is uploaded once'); + }); +}); diff --git a/modules/arrow-layers/test/lu-graph-deck.spec.ts b/modules/arrow-layers/test/lu-graph-deck.spec.ts new file mode 100644 index 0000000000..8dd73af60f --- /dev/null +++ b/modules/arrow-layers/test/lu-graph-deck.spec.ts @@ -0,0 +1,395 @@ +// luma.gl +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors + +import type {EffectContext} from '@deck.gl/core'; +import { + LuGraphDeckEffect, + LuGraphEdgeLayer, + LuGraphNodeLayer +} from '@deck.gl-community/arrow-layers'; +import {Buffer} from '@luma.gl/core'; +import {ShaderAssembler} from '@luma.gl/shadertools'; +import type {GPUVector} from '@luma.gl/tables'; +import {getWebGPUTestDevice} from '@luma.gl/test-utils'; +import test from 'test/utils/vitest-tape'; +import {vi} from 'vitest'; + +import {createLuGraphExplorerDeck} from '../../../examples/deck/lugraph-explorer/app'; +import {makeGraphExplorerDataset} from '../../../examples/experimental/lugraph-explorer/graph-data'; + +test('luGraph deck.gl effect composes actual GPU analytics, zero-copy selection, and pinned layout', async tapeTest => { + const device = await getWebGPUTestDevice(); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + let effect: LuGraphDeckEffect | undefined; + const submitSpy = vi.spyOn(device, 'submit'); + try { + const dataset = makeGraphExplorerDataset(); + effect = new LuGraphDeckEffect(device, dataset); + tapeTest.equal( + submitSpy.mock.calls.length, + 0, + 'effect construction never submits hidden GPU work' + ); + submitSpy.mockRestore(); + + tapeTest.deepEqual( + effect.graph.sourceVertices.data.map(chunk => chunk.length), + dataset.sourceChunks.map(chunk => chunk.length), + 'original nonempty, empty, and nonempty edge source batches remain intact' + ); + tapeTest.equal( + effect.positions, + effect.layout.positions.data[0].buffer, + 'Deck nodes consume the exact progressive layout allocation without copying' + ); + tapeTest.equal( + effect.positions.usage & (Buffer.STORAGE | Buffer.VERTEX), + Buffer.STORAGE | Buffer.VERTEX, + 'shared graph coordinates are simultaneously writable storage and vertex attributes' + ); + tapeTest.equal( + effect.importance, + effect.pageRank.output.data[0].buffer, + 'PageRank stays resident' + ); + tapeTest.equal( + effect.componentLabels, + effect.components.output.data[0].buffer, + 'weak-component colors read the original GPU output allocation' + ); + tapeTest.equal( + effect.selectionMask, + effect.search.mask!.data[0].buffer, + 'Deck highlighting reads source-aligned GPU neighborhood masks' + ); + + effect.setPinnedVertex(7, true); + effect.setVertexPosition(7, [0.375, -0.25]); + effect.setNeighborhoodDepth(2); + const firstEncoder = device.createCommandEncoder({id: 'lugraph-deck-effect-real-analysis'}); + effect.analysisGraph.encode(firstEncoder, {parameters: undefined}); + effect.frameGraph.encode(firstEncoder, {parameters: undefined}); + device.submit(firstEncoder.finish()); + + const [counts, reverseCounts, componentLabels, importance, distances, mask, pins, positions] = + await Promise.all([ + readUint32Vector(effect.topology.forward.count), + readUint32Vector(effect.topology.reverse!.count), + readUint32Vector(effect.components.output), + readFloat32Vector(effect.pageRank.output), + readUint32Vector(effect.search.distances), + readUint32Vector(effect.search.mask!), + readUint32Vector(effect.layout.pinned!), + readFloat32Coordinates(effect.layout.positions) + ]); + + tapeTest.equal(counts[0], effect.graph.edgeCount, 'GPU builds every original forward edge'); + tapeTest.equal(reverseCounts[0], effect.graph.edgeCount, 'GPU builds exact reverse adjacency'); + tapeTest.equal(componentLabels[0], 0, 'first weak community keeps its stable source ID'); + tapeTest.equal(componentLabels[64], 64, 'disconnected component retains its minimum source ID'); + tapeTest.equal( + componentLabels[effect.graph.vertexCount - 1], + effect.graph.vertexCount - 1, + 'isolated node remains its own weak component' + ); + tapeTest.ok( + Math.abs(importance.reduce((sum, score) => sum + score, 0) - 1) < 5e-5, + 'actual GPU PageRank sizing remains normalized' + ); + tapeTest.equal(distances[0], 0, 'GPU neighborhood root matches the stable selected vertex'); + tapeTest.equal(mask[0], 1, 'source-aligned mask highlights the selected vertex'); + tapeTest.equal( + mask[effect.graph.vertexCount - 1], + 0, + 'disconnected vertices remain unselected' + ); + tapeTest.equal(pins[7], 1, 'dragging pins the requested resident vertex row'); + tapeTest.ok(Math.abs(positions[14] - 0.375) < 1e-6, 'pinned X survives force integration'); + tapeTest.ok(Math.abs(positions[15] + 0.25) < 1e-6, 'pinned Y survives force integration'); + + effect.setSelectedVertex(64); + effect.setNeighborhoodDepth(1); + const secondEncoder = device.createCommandEncoder({id: 'lugraph-deck-effect-selected-root'}); + effect.frameGraph.encode(secondEncoder, {parameters: undefined}); + device.submit(secondEncoder.finish()); + const [updatedDistances, updatedMask] = await Promise.all([ + readUint32Vector(effect.search.distances), + readUint32Vector(effect.search.mask!) + ]); + tapeTest.equal(updatedDistances[64], 0, 'newly picked stable source ID becomes the GPU root'); + tapeTest.equal(updatedMask[64], 1, 'new root directly updates the Deck highlight buffer'); + tapeTest.equal(updatedMask[0], 0, 'unrelated components stop receiving selection highlights'); + } finally { + submitSpy.mockRestore(); + effect?.cleanup({} as EffectContext); + } + + tapeTest.end(); +}); + +test('luGraph deck.gl renders real source-chunk layers and asynchronously picks stable GPU node IDs', async tapeTest => { + const device = await getWebGPUTestDevice('core'); + if (!device) { + tapeTest.comment('WebGPU is not available'); + tapeTest.end(); + return; + } + + const canvasContext = device.getDefaultCanvasContext(); + const canvas = canvasContext.canvas; + if (!(canvas instanceof HTMLCanvasElement)) { + tapeTest.fail('real deck.gl integration requires an HTML canvas presentation surface'); + tapeTest.end(); + return; + } + + const originalParent = canvas.parentNode; + const originalNextSibling = canvas.nextSibling; + const originalWidth = canvas.width; + const originalHeight = canvas.height; + const originalStyle = canvas.getAttribute('style'); + const originalDrawingBufferSize = canvasContext.getDrawingBufferSize(); + const container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + left: '0', + top: '0', + width: '320px', + height: '240px', + overflow: 'hidden' + }); + document.body.appendChild(container); + container.appendChild(canvas); + canvas.width = 320; + canvas.height = 240; + canvas.style.width = '320px'; + canvas.style.height = '240px'; + canvasContext.setDrawingBufferSize(320, 240); + + const framebuffer = device.createFramebuffer({ + id: 'lugraph-deck-presentation-test-framebuffer', + width: 320, + height: 240, + colorAttachments: [device.preferredColorFormat], + depthStencilAttachment: 'depth24plus' + }); + // Shared SwiftShader suites can outlive Dawn's external presentation instance. Retain the real + // Deck render pass, layer pipelines, queue submission, and native GPU picking on an owned target. + const currentFramebuffer = vi + .spyOn(canvasContext, 'getCurrentFramebuffer') + .mockReturnValue(framebuffer); + let deck: ReturnType | undefined; + const originalShaderAssembler = ShaderAssembler.getDefaultShaderAssembler; + try { + deck = createLuGraphExplorerDeck(container, {device, dataset: makeGraphExplorerDataset(8)}); + deck.setProps({_animate: false}); + await waitForDeckEffect(deck); + tapeTest.equal( + ShaderAssembler.getDefaultShaderAssembler, + originalShaderAssembler, + 'the real application restores luma.gl shader-assembler isolation before Deck is ready' + ); + + const effect = deck.props.effects?.[0]; + tapeTest.ok( + effect instanceof LuGraphDeckEffect, + 'actual Deck owns a real resident graph effect' + ); + if (!(effect instanceof LuGraphDeckEffect)) { + throw new Error('Deck did not initialize its luGraph WebGPU effect'); + } + + const layers = deck.props.layers ?? []; + const edgeLayers = layers.filter(layer => layer instanceof LuGraphEdgeLayer); + const nodeLayer = layers.find(layer => layer instanceof LuGraphNodeLayer); + tapeTest.equal( + edgeLayers.length, + 2, + 'Deck creates exactly one layer per original nonempty edge chunk' + ); + tapeTest.deepEqual( + edgeLayers.map(layer => layer.id), + ['lugraph-edges-0', 'lugraph-edges-2'], + 'the empty source batch is preserved and not rendered or concatenated' + ); + tapeTest.ok( + nodeLayer instanceof LuGraphNodeLayer, + 'Deck instantiates the actual custom node layer' + ); + if (!(nodeLayer instanceof LuGraphNodeLayer)) { + throw new Error('Deck did not initialize its luGraph node layer'); + } + tapeTest.equal( + nodeLayer.getNumInstances(), + effect.graph.vertexCount, + 'Deck receives the real GPU vertex instance count despite the empty CPU data array' + ); + tapeTest.deepEqual( + edgeLayers.map(layer => layer.getNumInstances()), + [effect.graph.sourceVertices.data[0].length, effect.graph.sourceVertices.data[2].length], + 'Deck receives actual source-chunk edge counts without a CPU edge array' + ); + tapeTest.equal( + nodeLayer.props.positions, + effect.positions, + 'node attributes share exact GPU layout storage' + ); + tapeTest.equal( + edgeLayers[0].props.sourceVertices, + effect.graph.sourceVertices.data[0].buffer, + 'first edge model binds its original caller-owned source allocation' + ); + tapeTest.equal( + edgeLayers[1].props.sourceVertices, + effect.graph.sourceVertices.data[2].buffer, + 'second edge model binds the untouched third source partition' + ); + + await waitForDeckLayerModels([nodeLayer, ...edgeLayers]); + effect.setPinnedVertex(0, true); + effect.setVertexPosition(0, [0, 0]); + const positionsReadSpy = vi.spyOn(effect.positions, 'readAsync'); + const importanceReadSpy = vi.spyOn(effect.importance, 'readAsync'); + const submitSpy = vi.spyOn(device, 'submit'); + try { + deck.redraw('real WebGPU luGraph deck rendering and picking regression'); + tapeTest.ok( + currentFramebuffer.mock.calls.length > 0, + 'real Deck rendering targets an owned WebGPU framebuffer instead of a stale surface' + ); + tapeTest.ok(submitSpy.mock.calls.length > 0, 'real Deck layer passes submit GPU commands'); + tapeTest.ok( + nodeLayer.getModels()[0]?.pipeline, + 'actual node WGSL and vertex pipeline compile' + ); + for (const edgeLayer of edgeLayers) { + tapeTest.ok( + edgeLayer.getModels()[0]?.pipeline, + 'actual original-chunk edge WGSL pipeline compiles' + ); + } + + const projectedOrigin = deck.getViewports()[0].project([0, 0, 0]); + const pick = await deck.pickObjectAsync({ + x: Math.floor(projectedOrigin[0]), + y: Math.floor(projectedOrigin[1]), + radius: 3, + layerIds: ['lugraph-nodes'] + }); + + tapeTest.ok(deck.width > 1 && deck.height > 1, 'Deck uses a real multi-pixel viewport'); + tapeTest.equal( + pick?.index, + 0, + 'real asynchronous WebGPU Deck picking recovers stable source ID zero' + ); + tapeTest.equal( + pick?.layer?.id, + 'lugraph-nodes', + 'GPU picking identifies the actual node layer' + ); + tapeTest.equal( + positionsReadSpy.mock.calls.length, + 0, + 'normal rendering and explicit Deck picking never read graph positions back' + ); + tapeTest.equal( + importanceReadSpy.mock.calls.length, + 0, + 'node sizing and picking never download GPU PageRank scores' + ); + } finally { + positionsReadSpy.mockRestore(); + importanceReadSpy.mockRestore(); + submitSpy.mockRestore(); + } + } finally { + deck?.finalize(); + currentFramebuffer.mockRestore(); + framebuffer.destroy(); + canvas.width = originalWidth; + canvas.height = originalHeight; + if (originalStyle === null) canvas.removeAttribute('style'); + else canvas.setAttribute('style', originalStyle); + canvasContext.setDrawingBufferSize(originalDrawingBufferSize[0], originalDrawingBufferSize[1]); + if (originalParent) { + originalParent.insertBefore(canvas, originalNextSibling); + } else { + canvas.remove(); + } + container.remove(); + } + + tapeTest.equal( + ShaderAssembler.getDefaultShaderAssembler, + originalShaderAssembler, + 'finalizing the real Deck application never leaves a global shader-assembler override' + ); + + tapeTest.end(); +}); + +async function waitForDeckEffect( + deck: ReturnType +): Promise { + const deadline = performance.now() + 5_000; + while (!(deck.props.effects?.[0] instanceof LuGraphDeckEffect)) { + if (performance.now() >= deadline) { + throw new Error('The real WebGPU Deck did not finish initializing its luGraph effect'); + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } +} + +async function waitForDeckLayerModels( + layers: Array +): Promise { + const deadline = performance.now() + 5_000; + while (layers.some(layer => !getDeckLayerModelState(layer)?.pipeline)) { + if (performance.now() >= deadline) { + throw new Error('The real WebGPU Deck did not initialize its graph node and edge models'); + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + while (layers.some(layer => getDeckLayerModelState(layer)?.pipeline?.linkStatus === 'pending')) { + if (performance.now() >= deadline) { + throw new Error('The real WebGPU graph node and edge pipelines did not finish linking'); + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + for (const layer of layers) { + if (getDeckLayerModelState(layer)?.pipeline?.linkStatus !== 'success') { + throw new Error(`The real WebGPU ${layer.id} graph pipeline failed to link`); + } + } +} + +function getDeckLayerModelState( + layer: LuGraphNodeLayer | LuGraphEdgeLayer +): {pipeline?: {linkStatus?: string}} | undefined { + return (layer as unknown as {state?: {model?: {pipeline?: {linkStatus?: string}}}}).state?.model; +} + +async function readUint32Vector(vector: GPUVector<'uint32'>): Promise { + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 4); + return Array.from(new Uint32Array(bytes.buffer, bytes.byteOffset, vector.length)); +} + +async function readFloat32Vector(vector: GPUVector<'float32'>): Promise { + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 4); + return Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, vector.length)); +} + +async function readFloat32Coordinates(vector: GPUVector<'float32x2'>): Promise { + const chunk = vector.data[0]; + const bytes = await (chunk.buffer as Buffer).readAsync(chunk.byteOffset, vector.length * 8); + return Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, vector.length * 2)); +} diff --git a/modules/arrow-layers/tsconfig.json b/modules/arrow-layers/tsconfig.json index 7539218f38..8be3a2f8d7 100644 --- a/modules/arrow-layers/tsconfig.json +++ b/modules/arrow-layers/tsconfig.json @@ -11,6 +11,7 @@ {"path": "../arrow"}, {"path": "../core"}, {"path": "../engine"}, + {"path": "../experimental"}, {"path": "../tables"} ] } diff --git a/test/examples/gpgpu-catalog-navigation.node.spec.ts b/test/examples/gpgpu-catalog-navigation.node.spec.ts index acad84e607..d508963d85 100644 --- a/test/examples/gpgpu-catalog-navigation.node.spec.ts +++ b/test/examples/gpgpu-catalog-navigation.node.spec.ts @@ -219,6 +219,7 @@ describe('GPGPU example catalog navigation', () => { 'showcase/raster-lab', 'showcase/billion-point-spatial-atlas', 'deck/luspatial-taxi', + 'deck/lugraph-explorer', 'experimental/gpt-2' ]); }); diff --git a/website/content/examples/deck/lugraph-explorer.mdx b/website/content/examples/deck/lugraph-explorer.mdx new file mode 100644 index 0000000000..f4ed7f0e94 --- /dev/null +++ b/website/content/examples/deck/lugraph-explorer.mdx @@ -0,0 +1,117 @@ +--- +title: luGraph + deck.gl Network Explorer +description: Analyze, progressively lay out, and directly render one GPU-resident property graph using real deck.gl layers and asynchronous WebGPU picking. +sidebar_custom_props: + description: Explore a resident graph through GPU PageRank, components, layout, neighborhood selection, and direct deck.gl layers. + backends: [webgpu] + difficulty: advanced + maturity: experimental + topics: [compute, graphs, visualization, picking, deck.gl] +--- + +import {DeckLuGraphExplorerExample} from '@site/src/examples'; + +## Overview + +A social network, service-dependency map, transaction investigation, or citation graph becomes +easier to understand when its relationships are visible. This WebGPU-only example draws a +deterministic 128-vertex graph with real deck.gl layers while its adjacency, importance scores, +connected groups, neighborhood selection, and moving positions remain on the GPU. + + + +## Why combine luGraph and deck.gl? + +An application that already owns GPU graph data should not need to download every edge, construct a +JavaScript object for every vertex, and upload changing positions again just to render a network. +luGraph computes relationship data; deck.gl already understands application views, layer +lifecycle, camera controls, picking, and GPU drawing. This integration lets each system keep its +existing responsibility while sharing the same actual GPU allocations. + +A reusable `LuGraphDeckEffect` schedules luGraph work inside the existing deck.gl frame. +`LuGraphNodeLayer` and `LuGraphEdgeLayer` consume its results directly, and an `OrthographicView` +provides familiar pan and zoom. Those reusable adapters and all graph integration-specific +deck.gl imports live in the existing private `@deck.gl-community/arrow-layers` module. Graph +analytics remain in `@luma.gl/experimental/lugraph`, while this unpackaged example imports the +adapter's public symbols without importing `@deck.gl/core` directly. + +## When should I use this integration? + +Use this approach when a deck.gl application already has GPU-resident relationship data or will +reuse graph analytics across many interactive frames: + +- **Social and communication networks:** reveal influential accounts, disconnected groups, and + introductions within a selected number of hops. +- **Service and package dependencies:** inspect a failed service's reachable dependencies and + identify structurally important systems. +- **Transaction and fraud investigations:** follow relationships around an account and expose + connected groups of counterparties. +- **Knowledge and citation maps:** compare incoming PageRank influence with visual neighborhoods + and relationship structure. + +A small one-off graph that starts and stays in JavaScript may be simpler to process on the CPU. +Initial GPU upload, pipeline compilation, queue submission, and requested picking still have real +costs; keeping a graph resident does not automatically make every workload faster. + +## How the GPU-resident frame works + +1. **Upload the demonstration fixture once.** Its source and target relationship columns retain + their three original aligned batches: one nonempty batch, one intentionally empty batch, and a + second nonempty batch. +2. **Analyze the graph in the first frame.** `LuGraphDeckEffect` builds forward and reverse + compressed adjacency, computes normalized dangling-aware PageRank, and labels weakly connected + components. Weak components describe connectivity when edge direction is ignored; they are not + community-detection results. +3. **Update interaction and layout in later frames.** GPU breadth-first search highlights the + selected vertex's bounded neighborhood, and exact force-directed layout progressively advances + the existing node positions. +4. **Draw the same allocations through real deck.gl layers.** `LuGraphNodeLayer` binds the actual + `float32x2` layout allocation as an instanced vertex attribute. PageRank determines node size, + weak-component labels determine color, and resident distances and selection masks determine + highlighting. One `LuGraphEdgeLayer` draws each nonempty original source/target batch directly; + the empty middle batch remains in the graph and is never concatenated or repacked. + +The effect records compute into deck.gl's own WebGPU command encoder; deck.gl owns queue +submission. The node-position allocation has both `Buffer.STORAGE` and `Buffer.VERTEX` usage, so +simulation can update the exact same memory that the node layer fetches as an instance attribute. + +## Try the controls + +- **Hover a node** to inspect its stable original vertex identifier and whether it is pinned. +- **Click a node** to select it and highlight its GPU-computed neighborhood; click empty space to + clear the selection. +- **Adjust neighborhood depth** to include between zero and eight unweighted relationship hops. +- **Drag a node** to move its existing GPU coordinates and pin it while the remaining graph moves. +- **Release pins** to let every pinned vertex move again. +- **Reset layout** to restore deterministic initial positions for unpinned vertices. +- **Pan and zoom** by dragging empty space or using the scroll wheel in the orthographic view. + +These controls update small existing GPU selection, depth, pin, or position buffers; they do not +rebuild a JavaScript graph or download vertex columns for every animation frame. + +## What actually stays on the GPU + +After the initial fixture upload, original source and target edge chunks, compressed adjacency, +PageRank scores, weak-component identifiers, breadth-first distances, neighborhood masks, and +progressive layout positions stay resident. Rendering does not concatenate edge batches, copy +positions into a second vertex allocation, or read graph columns back to the CPU. + +Interaction is not magically transfer-free: JavaScript writes explicit selection controls, pin +flags, or dragged coordinates when requested. deck.gl's native asynchronous WebGPU picking also +returns a requested selected-vertex result to JavaScript. The returned `PickingInfo.index` is the +stable original vertex identifier; it is not a download of graph positions, PageRank scores, or +edge columns. deck.gl owns that picking implementation and its transfer size, so this example +does not claim the separate native luGraph explorer's custom integer-readback contract. + +## Boundaries and performance + +This example requires WebGPU and uses exact force-directed layout, which costs `O(V² + E)` per +iteration for `V` vertices and `E` edges. Its deliberately small 128-vertex network demonstrates +correct GPU ownership, native deck.gl integration, and interaction; it is not a large-graph +benchmark and does not enable the optional spatial approximation or provide a CPU fallback. + +See the [luGraph API guide](/docs/api-reference/experimental/lugraph) for graph ownership, +overflow handling, the separate optional spatial-layout approximation, and opt-in live CPU/GPU +benchmarks. The [native luGraph explorer](/examples/experimental/lugraph-explorer) demonstrates a +different renderer, a richer graph-inspector dashboard, and its own explicitly documented picking +path. diff --git a/website/content/examples/table-of-contents.json b/website/content/examples/table-of-contents.json index 96debd2f54..35ddbe9e07 100644 --- a/website/content/examples/table-of-contents.json +++ b/website/content/examples/table-of-contents.json @@ -112,6 +112,11 @@ "id": "deck/luspatial-taxi", "label": "luProj + luSpatial: Taxi Explorer" }, + { + "type": "doc", + "id": "deck/lugraph-explorer", + "label": "luGraph + deck.gl: Network Explorer" + }, "experimental/gpt-2" ] } diff --git a/website/src/example-thumbnails.ts b/website/src/example-thumbnails.ts index fd341e5978..2260aa1ffe 100644 --- a/website/src/example-thumbnails.ts +++ b/website/src/example-thumbnails.ts @@ -4,6 +4,7 @@ const EXAMPLE_THUMBNAIL_ALIASES: Readonly> = { 'v10/gpgpu': 'gpu-tables/gpu-vector-storage-particles', + 'deck/lugraph-explorer': 'showcase/packet-spraying', 'experimental/lugraph-explorer': 'showcase/packet-spraying', 'experimental/gpu-trace-scene': 'experimental/gpu-trace-viewer', 'experimental/gpu-scene-graph': 'experimental/gpu-frustum-culling', diff --git a/website/src/examples.tsx b/website/src/examples.tsx index aaf896ccc2..d84b7b7d65 100644 --- a/website/src/examples.tsx +++ b/website/src/examples.tsx @@ -118,6 +118,7 @@ const loadMillionRowCrossfilterExample = () => import('../../examples/showcase/million-row-crossfilter/app'); const loadRasterLabExample = () => import('../../examples/showcase/raster-lab/app'); const loadLuSpatialTaxiExample = () => import('../../examples/deck/luspatial-taxi/app'); +const loadLuGraphExplorerDeckExample = () => import('../../examples/deck/lugraph-explorer/app'); const loadFP64Example = () => import('../../examples/experimental/fp64/app'); type WebsiteExampleProps = React.PropsWithChildren< @@ -468,6 +469,41 @@ export const DeckLuSpatialTaxiExample: React.FC = ({ ); }; +/** Loads the optional deck.gl graph integration only when its WebGPU example is opened. */ +export const DeckLuGraphExplorerExample: React.FC = ({ + embedded = false +}) => { + const {module, errorMessage} = useDeferredExampleModule(loadLuGraphExplorerDeckExample); + + if (!module) { + return ( + + ); + } + + return ( + + ); +}; + type DeckArrowLayerExampleId = 'path' | 'polygon' | 'text'; const DECK_ARROW_LAYER_DOC_EXAMPLES: Array<{ diff --git a/yarn.lock b/yarn.lock index 9ac9ba693a..957aab8946 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2688,6 +2688,7 @@ __metadata: "@luma.gl/arrow": "npm:9.4.0-alpha.4" "@luma.gl/core": "npm:9.4.0-alpha.4" "@luma.gl/engine": "npm:9.4.0-alpha.4" + "@luma.gl/experimental": "npm:9.4.0-alpha.4" "@luma.gl/shadertools": "npm:9.4.0-alpha.4" "@luma.gl/tables": "npm:9.4.0-alpha.4" apache-arrow: "npm:^17.0.0"