Skip to main content

Custom Operations

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 Operation subclass that describes the lazy output table
  • one or more OperationHandler functions 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/gpgpu';

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/gpgpu';

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/gpgpu';

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/gpgpu';

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/gpgpu';
import * as cpuBackend from '@luma.gl/gpgpu/cpu';
import * as webglBackend from '@luma.gl/gpgpu/webgl';
import * as webgpuBackend from '@luma.gl/gpgpu/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.name and 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 supplied GPUData buffers 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, or webgpu augments the previously registered module for that device type. Incoming handler keys win on conflicts.