Skip to main content

Getting started with graphs

A graph describes a GPU computation before its input data is supplied. Declare the external inputs, add operations, choose the results you want to keep, and compile. Each execution binds actual data; operations define intermediate values and the graph manages their storage.

Start with GPUGraph on a WebGPU device. For the shared expression subset on WebGL, use GPUSimpleGraph. A semantic operation is not automatically portable between the two implementations.

1. Describe the computation​

import {GPUGraph, GPUExpressionOperation, gpuGraph} from '@luma.gl/graph';

const graph = new GPUGraph();
graph.addInput('values', gpuGraph.vector('float32'));
graph.addInput('scale', gpuGraph.scalar('float32'));
graph.add([
new GPUExpressionOperation({output: 'scaled', expression: 'values * scale'}),
new GPUExpressionOperation({output: 'result', expression: 'scaled + 1'})
]);

Names connect operations. values and scale are inputs; scaled and result are produced values. There is no separate declaration or buffer allocation for either produced value. Add producers before consumers. The expression reference explains functions, casts, typed rows, component access, and selection.

gpuGraph.vector('float32') describes the stored row format, not its length. Length and chunk boundaries come from the GPUVector supplied for each execution. A float32x3 vector has three components per row; it is not three separate graph values. gpuGraph.scalar('float32') accepts a JavaScript number at execution time.

2. Compile once, supply data per execution​

The following continuation accepts an already-created WebGPU Device. It uploads three rows and explicitly reads back the result to make the example verifiable. Applications can instead keep the result on the GPU for rendering or another graph.

import {Buffer, type Device} from '@luma.gl/core';
import {GPUData, GPUVector} from '@luma.gl/graph/gpu-data';

async function runExample(device: Device): Promise<number[]> {
const compiled = graph.compile(device, {outputs: ['result']});
let input: GPUVector | undefined;
let outputs: Record<string, GPUVector> | undefined;
try {
input = new GPUVector({
type: 'data',
format: 'float32',
ownsData: true,
data: [new GPUData({
buffer: device.createBuffer({
data: new Float32Array([1, 2, 3]),
usage: Buffer.STORAGE | Buffer.COPY_SRC | Buffer.COPY_DST
}),
format: 'float32',
length: 3,
ownsBuffer: true
})]
});
outputs = await compiled.run({inputs: {values: input, scale: 2}});
const bytes = await outputs.result.data[0].buffer.readAsync(0, 12);
return Array.from(new Float32Array(bytes.buffer, bytes.byteOffset, 3)); // [3, 5, 7]
} finally {
outputs?.result.destroy();
input?.destroy();
compiled.destroy();
}
}

run() prepares storage, encodes, and submits. Its promise returns GPU results; it does not mean GPU work has completed. The explicit readAsync() above waits for readback. Reuse the compiled graph with different input vectors or scalar values instead of rebuilding it for each batch. outputs selects values to retain, not a list of buffers that the caller must allocate.

3. Understand ownership​

ResourceOwnerCleanup
Input vector and initialized stateCallerKeep alive through submission and subsequent uses
Unselected operation resultPrepared executionCompiler-managed transient storage; eligible results can reuse allocations
Returned allocated outputCallerDestroy when no longer needed by submitted or future consumers
Caller-supplied output targetCallerBorrowed by the execution; never destroyed by graph cleanup
Reusable compiled graphCallerDestroy after its executions have been submitted or abandoned

Chunk boundaries are preserved. Expressions require matching vector-input partitions; the graph does not silently concatenate or repack data. Reuse is within one invocation, not a cache shared between concurrent executions. See values and storage for state, targets, lifetime rules, and allocation limits.

4. Compose with a frame​

Use compiled.prepare({inputs, state, outputTargets}) when the application owns submission. Its outputs are available for binding immediately. Call execution.encode(encoder), append other graph or rendering commands, submit once, then execution.destroy() to release scratch. Each prepared execution encodes once. Outputs remain caller-owned even if the execution is cancelled. The caller-owned encoding example shows complete failure cleanup.

Where to go next​

GPU-generated sizes, staged exact-size allocation, automatic results inside control flow, and reusable texture bindings are not part of this path. Advanced fixed-shape command graphs have their own resource and execution contracts.