Skip to main content

GPUExpressionOperation

GPUExpressionOperation describes a numeric expression over named graph declarations. Both GPUGraph and GPUSimpleGraph accept it through graph.add(). Expressions are parsed with @math.gl/core; its JavaScript evaluator is not used to execute GPU work. The same expression works on WebGL through SimpleGraph and on WebGPU through either graph.

This page is the expression syntax reference as well as the operation API guide. For the complete authoring and execution lifecycle, start with Getting started.

Declare, compile, and bind​

Declare external inputs; expressions define their own results. No output declaration is needed. See Graph inputs, results, and state for the shared ownership model.

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

const graph = device.type === 'webgpu' ? new GPUGraph() : new GPUSimpleGraph();
graph.addInput('positions', gpuGraph.vector('float32'));
graph.addInput('scale', gpuGraph.scalar('float32'));
graph.addInput('offset', gpuGraph.vector('float32'));
graph.add(new GPUExpressionOperation({
output: 'transformed',
expression: 'positions * scale + offset'
}));

const compiled = graph.compile(device, {outputs: ['transformed']});
const first = await compiled.run({inputs: {positions, scale: 2, offset}});
const second = await compiled.run({inputs: {positions: nextPositions, scale: 3, offset: nextOffset}});
// Each result is independent, including when the second input has a different length.
// After GPU consumers have finished:
first.transformed.destroy();
second.transformed.destroy();
compiled.destroy();

Declarations contain types, not data. An omitted vector length is resolved from execution bindings. An explicit length, such as gpuGraph.vector('float32', {length: 1024}) passed to addInput(), constrains all executions. Inputs are borrowed GPUVector objects or numeric scalar values. Expressions use float32, sint32, and uint32 scalars and packed vector rows of one to four components (float32, float32x2, float32x3, float32x4, and corresponding integer formats). Arithmetic operands must have float32 components; use explicit casts for integer inputs.

Syntax at a glance​

SyntaxMeaningExample
Declared nameExecution-time scalar or vector input, or preceding outputpositions, scale
Numeric literalFinite float32 number1, 0.25, 1e-3
(...)Grouping; multiplication/division precede addition/subtraction(positions + offset) * scale
-x, +, -, *, /Component-wise float32 arithmeticpositions * scale
abs, sqrt, sin, cos, tan, exp, logOne-argument component-wise float32 functionssqrt(abs(values))
pow(x, exponent)Component-wise float32 powerpow(values, 2)
float32, sint32, uint32Explicit, width-preserving numeric castssint32(positions)
.x, .yz, .zyx, .xxStatic component selection, reordering, repetitionpositions.zyx
vec2, vec3, vec4Concatenate components or replicate one componentvec4(positions, 1)
dot(x, y)Sum component products within each rowdot(positions, directions)
length(x)Euclidean magnitude within each rowlength(positions)
equalAll(x, y)All components equal; one uint32 0/1 per rowequalAll(ids, targetIds)
select(condition, whenTrue, whenFalse)Choose values using a numeric conditionselect(mask, colors, vec4(0))

These are the complete supported operators and function names; JavaScript's broader syntax and math.gl's other functions are not implicitly available.

Expression strings and ASTs​

The expression property accepts a string or the AST returned by math.gl's parse():

import {parse} from '@math.gl/core';
const operation = new GPUExpressionOperation({
output: 'transformed',
expression: parse('positions * scale + offset')
});

Supported expressions contain declared identifiers, finite numeric literals, parentheses, unary negation, binary +, -, *, /, and the function calls described below. Operators act elementwise; scalars broadcast to vector rows. Execution-sized vector outputs require a vector input; inferred constant rows have length one. Scalar expressions produce a one-row output vector with the declared numeric format. Use equalAll and select for equality and conditional value selection; JavaScript comparison and ternary operators are not supported. Parsing successfully in math.gl does not imply support by the GPU compiler.

Compilation rejects undeclared identifiers, incompatible formats, duplicate expression writers, forward references to expression outputs, and unsupported syntax. Add producers before their consumers. Expressions nested inside conditional or loop composites are not yet supported. Runtime checks enforce the supplied device, formats, declared lengths, and relationships between input dimensions.

Typed rows and component-wise execution​

The vector format fixes each row's component type and width. Vector length remains the number of rows and is supplied at execution; width is not another runtime dimension.

graph.vector('positions', 'float32x3');
graph.vector('offsets', 'float32x3');
graph.scalar('scale', 'float32');
graph.vector('transformed', 'float32x3');
graph.vector('indices', 'sint32x3');
graph.add(new GPUExpressionOperation({
output: 'transformed',
expression: 'positions * scale + offsets'
}));
graph.add(new GPUExpressionOperation({
output: 'indices',
expression: 'sint32(transformed)',
integerRounding: 'nearest'
}));

Arithmetic and component-wise math calls operate independently on each component. One-component operands broadcast across the other operand's components; other width mismatches fail at compilation. This includes one-component vector rows: a float32 vector can supply one weight per row for a float32x3 vector, with matching row counts and chunk boundaries. Scalar declarations still accept a single numeric binding, which broadcasts across all rows and components.

Casts preserve width: sint32(positions) produces sint32x3, applying rounding and saturation independently per component. An explicitly supplied output declaration must match the inferred component type and width. Integer arithmetic requires an explicit conversion back to float32.

Storage stays packed: float32x3 occupies 12 bytes per row on both backends, without WGSL vec3 padding. Inputs and caller-provided output targets must have the matching packed row stride and a four-byte-aligned offset. Existing chunk boundaries and ownership rules apply.

Row functions can change the component width, but do not change the number of rows. Existing evaluator operations and their APIs remain unchanged.

Component access and row construction​

Static .xyzw selectors choose one to four components per row. positions.x returns one component, positions.zyx reorders a three-component row, and positions.xx repeats its first component. Selectors can be nested and applied to subexpressions. A selector must fit the input width; .w on a three-component row fails at compilation. Computed indexing, .rgba aliases, and arbitrary properties are not supported.

vec2(...), vec3(...), and vec4(...) concatenate argument components in order. A single one-component argument replicates to the requested width. Otherwise the total component count must exactly match the constructor width; there is no implicit padding or truncation.

graph.vector('positions', 'float32x3');
graph.vector('homogeneous', 'float32x4');
graph.vector('reordered', 'float32x3');
graph.add(new GPUExpressionOperation({
output: 'homogeneous',
expression: 'vec4(positions, 1)'
}));
graph.add(new GPUExpressionOperation({
output: 'reordered',
expression: 'homogeneous.zyx'
}));

Constructors infer their component type from their arguments and require matching types. For example, vec3(ids.xy, uint32(1)) preserves exact uint32 values. Numeric literals are float32, so vec3(ids.xy, 1) requires an explicit cast when ids is uint32. Existing width-preserving casts apply to constructed rows and selections.

One-component selections broadcast in arithmetic: positions + positions.y adds each row's second component to every component in that row. Scalar-only constructors can be used inside expressions, such as positions + vec3(scale). An execution-sized vector output needs a vector input to determine its runtime row count. Chunk-boundary and ownership rules are unchanged; returned outputs are materialized results, even when a selector can use a borrowed intermediate view.

Compilation reports the operation ID with concise width/type diagnostics, for example reorder: .w needs 4 components; positions has 3.

Math functions and numeric casts​

Expressions accept abs(x), sqrt(x), pow(x, exponent), sin(x), cos(x), tan(x), exp(x), and log(x). Arguments may be nested expressions and named inputs. These functions operate on float32 values, and vector inputs are evaluated row by row. Numeric literals are float32; use integer declarations to retain exact 32-bit integer inputs. Only the documented bare function names are accepted: Math.sin(x), arbitrary functions, and incorrect argument counts are rejected at compilation. Function names are intrinsic, not callable values supplied through bindings.

Use float32(x), sint32(x), and uint32(x) for explicit numeric value conversions. When an output is explicitly declared, its type must match the expression result. For example:

graph.vector('indices', 'uint32');
graph.vector('weights', 'float32');
graph.add(new GPUExpressionOperation({
output: 'weights',
expression: 'sqrt(float32(indices))'
}));

Float-to-integer casts default to truncation toward zero and saturate to the destination range. Set integerRounding: 'nearest' on an expression operation to round to the nearest integer, with exact half-way ties away from zero, before saturation:

graph.add(new GPUExpressionOperation({
output: 'indices',
expression: 'uint32(position)',
integerRounding: 'nearest' // default: 'truncate'
}));

The option applies to every float-to-integer cast in that operation, including nested casts. Different operations in the same graph can select different modes. It does not change integer-to-integer casts, conversion to float32, or arithmetic. Both backends use the same rounding rule; for example, nearest maps -1.5 to -2 and 1.5 to 2 before saturation. Integer-to-integer casts also saturate; they do not reinterpret bits or wrap. Converting integers to float32 may lose precision. Integer input bindings must be integral and in range. Arithmetic and float32 math calls require float32 operands, so uint32(x) + 1 requires an explicit conversion back to float32 in this initial subset.

These casts apply to finite numeric values. Results of out-of-domain math, overflow, and casts of nonfinite intermediate values have no cross-backend guarantee. Use positive bases for portable pow results, positive arguments for log, and nonnegative arguments for sqrt; transcendental results follow backend float32 accuracy rather than bitwise JavaScript equivalence.

Numeric casts do not add normalized or packed storage formats to expression graphs. The existing castData evaluator retains its separate physical-format conversion and rounding behavior. Pure arithmetic/function expressions retain evaluator fusion. Casts introduce explicit conversion stages on SimpleGraph; WebGPU stages share its single evaluation scope, and WebGL uses GPU transform feedback without CPU readback.

Row math, equality, and selection​

All four functions are row-local: they preserve row counts and chunk boundaries. They can be nested with arithmetic, casts, constructors, and selectors.

FunctionInput rulesResult
dot(x, y)Float32 components; identical widths from 1 to 4One float32 component per row
length(x)Float32 components; width 1 to 4One float32 component per row
equalAll(x, y)Identical component types and widthsOne uint32 component, either 0 or 1, per row
select(condition, whenTrue, whenFalse)Branch component types must match; widths follow the rules belowThe branch component type and resolved branch width

Dot products and lengths​

dot(vec3(1, 2, 3), vec3(4, 5, 6)) is 32. length(vec2(3, 4)) is 5. For one-component rows, dot(x, y) is multiplication and length(x) is magnitude. Neither function reduces across rows: length(positions) gives one length for each position, not the number of positions or the length of the whole buffer.

dot requires equal widths; write dot(positions, vec3(scale)) to explicitly replicate a scalar. Integer inputs require a cast, such as length(float32(integerPositions)). The scalar row result broadcasts in surrounding arithmetic:

graph.vector('positions', 'float32x3');
graph.vector('distance', 'float32');
graph.vector('direction', 'float32x3');
graph.scalar('epsilon', 'float32'); // Bind a positive value, e.g. 1e-6.
graph.add(new GPUExpressionOperation({output: 'distance', expression: 'length(positions)'}));
graph.add(new GPUExpressionOperation({
output: 'direction',
expression: 'positions / (distance + epsilon)'
}));

The positive epsilon keeps the denominator nonzero. The result approaches unit length for positions whose magnitude is large relative to epsilon; zero positions remain zero.

Equality​

equalAll compares corresponding components without converting their type. It returns uint32(1) only when every component matches, otherwise uint32(0). Integer comparisons retain exact 32-bit values, including values beyond float32's exact integer range. Float comparisons are exact comparisons, not approximate comparisons with a tolerance.

There is no implicit width broadcast in equalAll. To compare every component to the same value, write equalAll(ids, vec3(targetId)), where both inputs have uint32 components.

Selection​

The argument order matches the existing evaluator API:

select(condition, whenTrue, whenFalse)

This differs from WGSL's builtin order, which places the false value first and condition last. Expression syntax always uses the condition-first order shown above.

  • Zero selects whenFalse; any nonzero numeric value selects whenTrue.
  • A one-component condition chooses a whole row. A multi-component condition chooses each component independently.
  • Branch component types must match. A one-component branch broadcasts to the other branch's width; otherwise branch widths must match.
  • The condition must have one component or the resolved branch width. It may use any of the three numeric component types, independently of the branch type. Fractional and negative nonzero conditions remain true even when selecting integer values.
  • Both branches are evaluated. Selection preserves rows; it neither filters nor compacts data.
graph.vector('ids', 'uint32');
graph.scalar('selectedId', 'uint32');
graph.vector('colors', 'float32x4');
graph.vector('highlighted', 'float32x4');
graph.add(new GPUExpressionOperation({
output: 'highlighted',
expression: 'select(equalAll(ids, selectedId), vec4(1, 1, 0, 1), colors)'
}));

Component-wise example: select(vec3(0, -2, 0.5), vec3(10), vec3(20)) produces vec3(20, 10, 10). Selecting integer branches preserves their type and precision; use uint32(0) instead of the float32 literal 0 for an integer fallback.

On SimpleGraph, row functions use existing evaluator handlers. Condition normalization happens before branch-type conversion. Row operations can introduce intermediate GPU passes; WebGPU still submits once. Full GPUGraph compiles the functions into each expression's shader.

Errors and unsupported syntax​

Compilation prefixes concise errors with the operation ID. Supplying id makes failures easier to locate; the default is <output>-expression.

Error or unsupported expressionFix
reorder: .w needs 4 components; positions has 3Choose an existing component or construct a wider row.
dot requires matching row widthsMatch both widths; explicitly replicate a scalar with vecN.
equalAll requires matching types and row widthsCast deliberately and match widths before comparing.
select branches require matching component typesCast one branch, including numeric literal fallbacks.
select widths must match its branches or be one componentUse a scalar condition or match the branch row width.
Arithmetic requires float32 operandsUse float32(...) around integer operands.
Math.sqrt(x)Use the bare function sqrt(x).
positions[0], positions.rgbaUse static .xyzw selectors.
x == y, condition ? a : bUse equalAll(x, y) and select(condition, a, b).

Assignments, arbitrary calls, logical/comparison operators, computed indexing, and general JavaScript objects are unsupported. Expressions cannot gather other rows, generate sequences, perform whole-vector reductions, or process variable-length rows. Use dedicated graph/evaluator operations for those tasks. Physical formats such as float16, narrow integers, normalized colors, and float64 high/low splitting remain outside the expression format subset.

Chunking and ownership​

Multiple vector operands must have equal lengths and matching chunk boundaries, including empty chunks. Outputs preserve that topology. The implementation never merges or repacks input chunks. Execution rejects incompatible boundaries instead of copying them implicitly.

compile(device, {outputs}) selects the results to retain. By default every expression output is selected. Returned allocated vectors belong to the caller and survive another run or compiled.destroy(). Unselected intermediate storage is released after execution. Input vectors remain caller-owned.

To supply output storage, pass run(bindings, {outputTargets: {transformed: destination}}). Targets must have the inferred numeric layout and chunk topology. Writable chunks within a target must not overlap. They are borrowed and must not alias inputs or other expression outputs. Results reference the supplied target objects.

Full-graph composition​

Expression results are ordinary logical graph values, so a subsequent WebGPU operation can consume them:

import {GPUGraphVectorMADD} from '@luma.gl/graph/gpu-core';

const graph = new GPUGraph();
const input = graph.vector('input', 'float32');
const transformed = graph.vector('transformed', 'float32');
const output = graph.vector('output', 'float32');
const scale = graph.scalar('scale', 'float32');
graph.add(new GPUExpressionOperation({output: 'transformed', expression: 'input * scale'}));
graph.add(new GPUGraphVectorMADD({input: transformed, scale, addend: input, output}));
const compiled = graph.compile(device, {outputs: ['output']});
const results = await compiled.run({input: inputVector, scale: 2, output: outputVector});

Expressions can also consume scalars produced by preceding GPUGraphDotProduct, GPUGraphScalarLiteral, or GPUGraphScalarOperation operations without a host binding. Those scalar results can be selected as outputs and are returned as one-row GPUVectors. Internal vector outputs from GPUGraphVectorMADD and GPUGraphSpMV are prepared before their consumers and can also be selected as results. MADD inherits its input chunk topology when no output topology is declared; other fixed-shape outputs use their declared dimensions.

The full graph encodes expressions and its other operations into one command encoder and submits once. Its expression shaders are compiled once and take dimensions at execution. Resource preparation and command-graph lowering happen per run. Existing non-expression operations may still specialize their shaders for concrete dimensions. Texture declarations are not yet supported by this reusable expression-program entry point; the existing GPUGraphCompiler.compile(program, bindings) API remains available for those programs.

SimpleGraph lowers the same expression subset through the existing arithmetic evaluators. WebGPU execution uses one evaluation scope; WebGL retains the existing evaluator submission behavior. Backend shader caches remain responsible for SimpleGraph shader reuse.

Compiled program objects​

GPUGraph.compile(device) returns CompiledGPUGraph. GPUSimpleGraph.compile(device) returns CompiledGPUSimpleExpressionGraph. Both expose run(bindings, options) and destroy(). Destroying a compiled program releases its retained compilation resources, not the caller's inputs or returned outputs.

Existing evaluator APIs​

GPUDataEvaluator, GPUVectorEvaluator, their operation functions, and direct evaluation remain unchanged. Legacy new GPUSimpleGraph(device).compile(options) graphs also retain their existing caching, scope, and result-ownership behavior. The new declaration-based path uses new GPUSimpleGraph() and compile(device, options); do not mix its operations with legacy evaluator adapters in one builder.