Custom evaluator operations
This page describes the evaluator API. For logical operations added to GPUGraph, see
GPUOperation contracts; their lowering interface is separate from
an evaluator Operation and its backend handlers.
Custom operations let applications add lazy GPUDataEvaluator operations that
are dispatched through backendRegistry, just like the built-in operations.
A custom operation has two parts:
- an
Operationsubclass that describes the lazy output table - one or more
OperationHandlerfunctions registered for the device types that will evaluate the operation
This example implements a Natural Earth projection operation named
projectNaturalEarth. The input must be vec2<f32> rows storing longitude and
latitude in degrees. The output is vec2<f32> rows storing projected x and
y coordinates.
Define the Operation​
The operation name is the backend lookup key. It must match the handler key
registered with backendRegistry.add().
import {getGPUDataEvaluator, GPUDataEvaluator, Operation, type GPUDataEvaluatorInput} from '@luma.gl/graph/gpu-simple-graph';
type ProjectNaturalEarthInputs = {
coordinates: GPUDataEvaluator;
};
class ProjectNaturalEarthOperation extends Operation<ProjectNaturalEarthInputs> {
name = 'projectNaturalEarth';
output: GPUDataEvaluator;
constructor(coordinates: GPUDataEvaluator) {
super({coordinates});
this.output = new GPUDataEvaluator({
isConstant: coordinates.isConstant,
type: 'float32',
size: 2,
length: coordinates.length,
source: this
});
}
toString(): string {
return `projectNaturalEarth(${this.inputs.coordinates})`;
}
}
export function projectNaturalEarth(coordinates: GPUDataEvaluatorInput): GPUDataEvaluator {
const coordinatesTable = getGPUDataEvaluator(coordinates);
if (coordinatesTable.type !== 'float32' || coordinatesTable.size !== 2) {
throw new Error('projectNaturalEarth() requires vec2<f32> longitude/latitude input');
}
return new ProjectNaturalEarthOperation(coordinatesTable).output;
}
Implement Operation Handlers​
CPU Backend​
An OperationHandler receives the selected device, operation inputs, logical
output table, and target GPU buffer. It must write the operation result into
target and return {success: true}. Returning value is optional, but it
caches the CPU copy on the output table.
Handlers may be synchronous or async. Synchronous handlers are compatible with
executeSync() / evaluateSync() as long as backend registration and all
required dependencies are already resolved.
import {type OperationHandler} from '@luma.gl/graph/gpu-simple-graph';
const RADIANS_PER_DEGREE = Math.PI / 180;
// https://en.wikipedia.org/wiki/Natural_Earth_projection
function naturalEarth(lon: number, lat: number): [number, number] {
const lambda = lon * RADIANS_PER_DEGREE;
const phi = lat * RADIANS_PER_DEGREE;
const phi2 = phi * phi;
const phi4 = phi2 * phi2;
return [
lambda *
(0.8707 -
0.131979 * phi2 +
phi4 * (-0.013791 + phi4 * (0.003971 * phi2 - 0.001529 * phi4))),
phi *
(1.007226 +
phi2 * (0.015085 + phi4 * (-0.044475 + 0.028874 * phi2 - 0.005916 * phi4)))
];
}
export const projectNaturalEarthCPU: OperationHandler<ProjectNaturalEarthInputs> = async ({
inputs,
output,
target
}) => {
const {coordinates} = inputs;
const source = await coordinates.readValue();
const value = new Float32Array(output.length * output.size);
for (let rowIndex = 0; rowIndex < output.length; rowIndex++) {
const [x, y] = naturalEarth(source[rowIndex * 2], source[rowIndex * 2 + 1]);
value[rowIndex * 2] = x;
value[rowIndex * 2 + 1] = y;
}
target.write(value);
return {success: true, value};
};
WebGL Backend​
The WebGL handler uses transform feedback through BufferTransform.
import {BufferTransform} from '@luma.gl/engine';
import {type OperationHandler} from '@luma.gl/graph/gpu-simple-graph';
const naturalEarth1WebGL = /* glsl */ `\
#version 300 es
in vec2 coordinates;
out vec2 projected;
const float RADIANS_PER_DEGREE = 0.017453292519943295;
vec2 naturalEarth(float lon, float lat) {
float lambda = lon * RADIANS_PER_DEGREE;
float phi = lat * RADIANS_PER_DEGREE;
float phi2 = phi * phi;
float phi4 = phi2 * phi2;
return vec2(
lambda * (
0.8707 -
0.131979 * phi2 +
phi4 * (-0.013791 + phi4 * (0.003971 * phi2 - 0.001529 * phi4))
),
phi * (
1.007226 +
phi2 * (0.015085 + phi4 * (-0.044475 + 0.028874 * phi2 - 0.005916 * phi4))
)
);
}
void main() {
projected = naturalEarth(coordinates.x, coordinates.y);
}
`;
export const projectNaturalEarthWebGL: OperationHandler<ProjectNaturalEarthInputs> = async ({
inputs,
output,
target
}) => {
const {coordinates} = inputs;
const transform = new BufferTransform(target.device, {
vs: naturalEarth1WebGL,
bufferLayout: [
{
name: 'coordinates',
stepMode: 'vertex',
byteStride: coordinates.stride,
attributes: [
{
attribute: 'coordinates',
format: 'float32x2',
byteOffset: coordinates.offset
}
]
}
],
vertexCount: output.length,
outputs: ['projected']
});
transform.run({
inputBuffers: {coordinates: coordinates.buffer},
outputBuffers: {projected: target}
});
transform.destroy();
return {success: true};
};
WebGPU Backend​
The WebGPU handler runs the same projection in a compute shader.
import {Computation} from '@luma.gl/engine';
import {type OperationHandler} from '@luma.gl/graph/gpu-simple-graph';
const WORKGROUP_SIZE = 64;
export const projectNaturalEarthWebGPU: OperationHandler<ProjectNaturalEarthInputs> = async ({
inputs,
output,
target
}) => {
const {coordinates} = inputs;
const sourceStride = coordinates.stride / Float32Array.BYTES_PER_ELEMENT;
const sourceOffset = coordinates.offset / Float32Array.BYTES_PER_ELEMENT;
const source = /* wgsl */ `
@group(0) @binding(0) var<storage, read> coordinates: array<f32>;
@group(0) @binding(1) var<storage, read_write> projected: array<f32>;
const RADIANS_PER_DEGREE: f32 = 0.017453292519943295;
fn naturalEarth(lon: f32, lat: f32) -> vec2<f32> {
let lambda = lon * RADIANS_PER_DEGREE;
let phi = lat * RADIANS_PER_DEGREE;
let phi2 = phi * phi;
let phi4 = phi2 * phi2;
return vec2<f32>(
lambda * (
0.8707 -
0.131979 * phi2 +
phi4 * (-0.013791 + phi4 * (0.003971 * phi2 - 0.001529 * phi4))
),
phi * (
1.007226 +
phi2 * (0.015085 + phi4 * (-0.044475 + 0.028874 * phi2 - 0.005916 * phi4))
)
);
}
@compute @workgroup_size(${WORKGROUP_SIZE}) fn main(
@builtin(global_invocation_id) globalId: vec3<u32>
) {
let rowIndex = globalId.x;
if (rowIndex >= ${output.length}u) {
return;
}
let inputIndex = ${sourceOffset}u + rowIndex * ${sourceStride}u;
let xy = naturalEarth(coordinates[inputIndex], coordinates[inputIndex + 1u]);
projected[rowIndex * 2u] = xy.x;
projected[rowIndex * 2u + 1u] = xy.y;
}
`;
const computation = new Computation(target.device, {
source,
shaderLayout: {
bindings: [
{name: 'coordinates', type: 'storage', group: 0, location: 0},
{name: 'projected', type: 'storage', group: 0, location: 1}
]
}
});
computation.setBindings({
coordinates: coordinates.buffer,
projected: target
});
const computePass = target.device.beginComputePass({});
computation.dispatch(computePass, Math.ceil(output.length / WORKGROUP_SIZE));
computePass.end();
target.device.submit();
computation.destroy();
return {success: true};
};
Register the Backends​
Register the handler for each device type that should evaluate the operation.
backendRegistry.add() merges the provided handlers into the backend module for
that device type. Spreading the built-in backend module is still a useful way to
show the full effective handler set explicitly.
import {backendRegistry, type BackendModule} from '@luma.gl/graph/gpu-simple-graph';
import * as cpuBackend from '@luma.gl/graph/gpu-simple-graph/cpu';
import * as webglBackend from '@luma.gl/graph/gpu-simple-graph/webgl';
import * as webgpuBackend from '@luma.gl/graph/gpu-simple-graph/webgpu';
backendRegistry.add('cpu', {
...cpuBackend,
projectNaturalEarth: projectNaturalEarthCPU
} satisfies BackendModule);
backendRegistry.add('webgl', {
...webglBackend,
projectNaturalEarth: projectNaturalEarthWebGL
} satisfies BackendModule);
backendRegistry.add('webgpu', {
...webgpuBackend,
projectNaturalEarth: projectNaturalEarthWebGPU
} satisfies BackendModule);
Use the Operation​
Custom operations return GPUDataEvaluator instances, so they can be evaluated
or chained with other GPGPU operations.
const coordinates = GPUDataEvaluator.fromArray(
new Float32Array([
-122.4194, 37.7749,
-74.006, 40.7128
]),
{size: 2}
);
const projected = projectNaturalEarth(coordinates);
await projected.evaluate(device);
const result = await projected.readValue();
Notes​
Operation.nameand the backend module key must be identical.- Dependencies are evaluated before the handler is called.
- WebGPU compute handlers bind input evaluators as storage buffers. Buffers
materialized by
GPUDataEvaluator.evaluate()are storage-bindable; externally suppliedGPUDatabuffers must also be created with storage usage. - Use
GPUVectorEvaluator.fromGPUVector(vector).mapGPUData(...)when the same custom leaf operation should run independently across preserved vector chunks. - Register a handler for every device type that may evaluate the operation.
- Registering additional handlers for
cpu,webgl, orwebgpuaugments the previously registered module for that device type. Incoming handler keys win on conflicts.