Data-driven geometry

Overview

To render live geometry, data sources will ingest your data and pack it into GPU buffers. You then pass them as props to one or more geometry layers. Each renders a particular kind of geometry in bulk: points, lines, labels, surfaces, etc.

By separating sources and layers, you can mix them à la carte. E.g. to draw multiple representations of the same data set.

The input can be either an array-of-structs or struct-of-arrays layout in JS, depending on which data component is used and how.

The output is a struct-of-arrays on the GPU, with a handle for each field. Except for StructData which is array-of-structs.

You can also just supply a constant value as a prop. This is distinguished by the singular (e.g. size, color) instead of a plural (sizes, colors).

Geometry layers

Data binding

In the simplest case, data binding will produce an array or a struct-of-arrays.

You can also use composite data where each item in the data has several data points. These can be variable length, e.g. to render N paths with 1..M segments each.

There is also a raw loader to fill and upload a single TypedArray to the GPU.

Array-of-Structs

This is the easiest format to work with. The input data is an array of JS records, each a single data point. E.g.:

const data = [
  {
    position: [0, 1, 2],
    color: [0.3, 0.5, 0.7],
    size: 5,
  },
  {
    position: [4, -1, 1],
    color: [0.4, 0.1, 0.5],
    size: 3,
  },
];

We need a matching schema for one data point. This has a WGSL type T for each field:

const schema = {
  'position': 'vec3<f32>',
  'color': 'vec3<f32>',
  'size': 'f32',     
};

We pass data and schema to <Data>:

import { Data } from '@use-gpu/workbench';

return (
  <Data
    schema={schema}
    data={data}
  >{
    ({positions, colors, sizes}) => {
      // ...
      return <Component />;
    }
  }</Data>
);

The data is converted to a struct-of-arrays, provided as 3 separate LambdaSource shader sources. They can be rendered with layers like <PointLayer>:

import { PointLayer } from '@use-gpu/workbench';

  return (
    <PointLayer
      positions={positions}
      colors={colors}
      sizes={sizes}
    />
  );

Composite Array-of-Structs

<Data> also allows you to use variable-length groups of data points. Here, each field of data actually has N values.

Not all fields need to be composite. We can make e.g. only position into an array positions:

import { Data } from '@use-gpu/workbench';

const schema = [
  positions: 'array<vec3<f32>>',
  color: 'vec3<f32>',
  size: 'f32',
};

const data = [
  {
    // 4 positions
    positions: [
      0, 0, 0,
      0, 1, 0,
      1, 1, 0,
      1, 0, 0,
    ],
    // 1 color and size
    color: [0.3, 0.4, 0.5],
    size: 5,
  },
  {
    // 5 positions
    positions: [
      2, 2, 1,
      2, 3, 1,
      3, 3, 1,
      2, 4, 2,
      3, 1, 2,
    ],
    // 1 color and size
    color: [0.5, 0.3, 0.4],
    size: 3,
  },
]

You will end up with 9 data points total. The non-array props color and size will be repeated appropriately, i.e. 4 + 5 times.

return (
  <Data
    schema={schema}
    data={data}
  >{
    ({positions, colors, sizes}) => {
      // ...
      return <Component />;
    }}
  />
);

Struct-of-Arrays (record)

As an alternative to a loose Struct-of-Arrays, you can use <GeometryData> to load a single CPUGeometry object and obtain a GPUGeometry with LambdaSources inside. This does the same thing as the previous example, but is less verbose. It's intended for passing a group of named attributes on, e.g. with prefab geometry:

import { makeBoxGeometry } from '@use-gpu/core';

const geometry = useOne(() => makeBoxGeometry());

return (
  <GeometryData geometry={geometry}>{
    (mesh: GPUGeometry) => {
      // ...
      return <Component />;
    }
  }</GeometryData>
);

Interleaved data

If your data is interleaved as classic vertex attributes, you can unpack them with <InterleavedData>. All elements must have the same numeric type (e.g. f32).

const vertexArray = [
  // vec4 position, vec4 normal, vec4 color, vec2 uv,
   1, -1, 1, 1,  0, -1, 0, 0,  1, 0, 1, 1,  0, 1,
  -1, -1, 1, 1,  0, -1, 0, 0,  0, 0, 1, 1,  1, 1,
  //...
];

const MESH_SCHEMA = {
  position: 'vec4<f32>',
  normal: 'vec4<f32>',
  color: 'vec4<f32>',
  uv: 'vec2<f32>',
};

return (
  <InterleavedData
    schema={MESH_SCHEMA}
    data={vertexArray}
  >{
    ({positions, normals, colors, uvs}) => {
      // ...
      return <Component />;
    }
  }</InterleavedData>
);

Typed array

To load a single flat array, use <RawData>:

import { RawData } from '@use-gpu/workbench';

const positions = [
  -5, -2.5,  0,
   5, -2.5,  0,
   0, -2.5, -5,
   0, -2.5,  5,
];

return (
  <RawData
    format='vec3<f32>'
    data={positions}
  >{
    (positions: StorageSource) => {
      // ...
      return <Component />;
    }
  }</RawData>
);

WGSL struct

Struct types defined in WGSL shaders can be imported, and used as the binary format for packed <StructData>.

This is intended for specialized layers or custom draw calls, and is the only way to produce an actual array-of-structs in WGSL. It will have indeterminate array length, variable at run-time.

The binary layout will be derived automatically, following WebGPU alignment rules.

e.g.

import { Light } from '@use-gpu/wgsl/use/types.wgsl';
import { StructData } from '@use-gpu/workbench';

const data = [
  {
    position: [0, 1, 2, 0],
    normal: [1, 0, 0, 0],
    tangent: [0, 1, 0, 0],
    size: 10,
    color: [0.5, 0.3, 0.6, 1.0],
    intensity: 1,
    kind: 2,
  },
  // ...
]

return (
  <StructData
    format={Light}
    data={data}
    render={(source: StorageSource) => {
      // ...
    }}
  />
);

Data emitters

To generate data on-the-fly procedurally, use Sampler from the plot package. Provide an expr that calls an Emitter. This is a function that emits one or more vectors per call, based on one or more indices (i, j, k, ...):

expr = (emit: Emit, i: number, j: number) => {
  // Emit a 2D point
  emit(i, j);
}

To emit multiple items per data point, set prop items to > 1 and call emit that many times. You will get an items x length sized array.

To emit sparse data, set prop sparse to true and choose whether to call emit on the fly. You must either call it 0 or N = items times. You will get an items x emitted sized array.

Set prop time to true to add the local TimeContext as the last argument to expr.

Raw emitter

<RawData> has a straight-forward 1D expr prop:

import type { Emit } from '@use-gpu/core';

return (
  <RawData
    format='vec3<f32>'
    length={100}
    expr={(emit: Emit, i: number) => {
      const s = ((i*i + i) % 13133.371) % 1000;

      // Generate random 3D point
      emit(
        Math.cos(s * 1.31 + Math.sin(s * 0.31) + s) * 2,
        Math.sin(s * 1.113 + Math.sin(s * 0.414) - s) * 2,
        Math.cos(s * 0.981 + Math.cos((s + s*s) * 0.515) + s*s) * 2,
      );
    }}
    render={(positions: StorageSource) => {
      // ...
    }}
  />
);

Tensor

Use <Tensor> to generate multi-dimensional arrays, where prop size is a number[], i.e. [width, height?, depth?, layers?]. This will live on the CPU and must be passed to <Data>.

Some layers, e.g. SurfaceLayer require multi-dimensional data.

E.g. a 2D array:

import { Tensor } from '@use-gpu/plot';

return (
  <Tensor
    format='vec3<f32>'
    size={[40, 30]}
    expr={(emit: Emit, i: number, j: number) => {
      // emit(...)
    }}
  >{
    (positions: TensorArray) => {
      // ...
    }
  }</Tensor>
);

The general signature for an array emitter is:

(
  emit: Emit,
  i: number, j: number, ... // Index along dimensions
  t: TimeContextProps,      // Time (if prop time=true)
) => {
  emit(...);
  emit(...);
  // ...
}

Sampled emitter

Use <Sampled> to produce a multi-dimensional array sampled on a given range [min, max][]:

import { Sampled } from '@use-gpu/workbench';

return (
  <Sampled
    range={[[-1, 1], [-2, 2]]}
    format='vec3<f32>'
    size={[40, 30]}
    expr={(emit: Emit, x: number, y: number, i: number, j: number) => {
      // emit(...)
      // emit(...)
    }}
  >{
    (positions: StorageSource) => {
      // ...
    }
  }</Sampled>
);

The general signature for an array emitter is:

(
  emit: Emit,
  x: number, y: number, ... // Sampled point
  i: number, j: number, ... // Index along dimensions (if prop index=true)
  t: TimeContextProps,      // Time (if prop time=true)
) => {
  emit(...);
  emit(...);
  // ...
}

Set centered to true to sample half-a-sample away from the edges, i.e. to sample at pixel centers. Centered can also be an array boolean[] to control centered sampling per axis.

Interleaved emitter

With items > 1, you can also emit interleaved data, producing an implicit array-of-structs layout.

When <RawData> prop interleaved is true, this data will be split into one source per field:

return (
  <RawData
    format='vec3<f32>'
    length={100}
    items={2}
    interleaved={true}
    expr={(emit: Emit, i: number) => {
      emit(...); // Position
      emit(...); // Color
    }}
  >{
    (positions: ShaderSource, colors: ShaderSource) => {
      // ...
    }
  }</RawData>
);

Both sources use the same de-interleaving shader to read from the same underlying GPU buffer, only with a different offset.

Struct emitter

<StructData> can be used with an emitter as well. Emit the fields in struct order:

expr = (emit: Emit, i: number) => {
  emit(2+i, 2+i, 2+i, 1); // position
  emit(0, 1, 0, 0);       // normal
  emit(1, 0, 0, 0);       // tangent
  emit(10);               // size
  // ...
};

<StructData> expressions can be sparse, but not items > 1.

Semantics

Live updating / reactivity

By default, a data component will only refresh if the data prop changes by value, or if the schema has changed. This means modifying data in-place does not work by default.

To handle this case, you can set the live prop to true. This will require a surrounding <Loop>, to allow the component to re-render continuously, so it is far less efficient.

It is also important not to define the schema directly during component render, but to define it ahead of time once, as a global constant. Otherwise, it is considered a new schema for every render, and all the buffers will be recreated constantly.

StorageSource and LambdaSource

A LambdaSource is a shader function with a known array size. Usually this function will read from a StorageSource.

Each StorageSource is a GPU array of a single data type, e.g. vec4<f32> or a WGSL struct. It consists of a GPU buffer handle, as well as additional metadata like array size, format, and so on.

As a handle, a StorageSource's data can change on the fly, without creating a new StorageSource. This means components that use a source will not be re-rendered when the data changes, even when it resizes.

This may be unexpected, however it is entirely in line with the concept of data-driven geometry: it is the job of individual low-level primitives and their shaders to adapt properly to input size, it should not be your concern.

Draw calls that use a source will evaluate its size just-in-time as needed. Derived sources will evaluate their own size lazily.

See Memoization for info on how changes are tracked for handles that do not change.

Segments and loops

Data is joined into one big array, so you need additional segment information to describe the boundaries between the original segments. For line segments, this is a simple integer code 1-3-2 for start-middle-end:

o-o-o-o  o-o-o  o-o-o-o-o-o
1 3 3 2  1 3 2  1 3 3 3 3 2

While you can provide segment data yourself, it is easiest to auto-generate it as the data is being composed.

Here, we pass a getLineSegments to <Data> via segments. This will cause an additional source to appear:

return (
  <Data
    schema={schema}
    data={data}
    segments={getLineSegments}
  >{
    ({
      positions,
      colors,
      sizes,
      segments, // added by `getLineSegments`
    }) => {
      // ...
      return <Component />;
    }}
  }</Data>
);

You can also draw closed loops of data, by passing a loop prop to <Data>. This is a function (item) => boolean so you can mix looped and non-looped data freely.

When looped, data points will be padded with wrapping, with one item at the front, and two at the back:

  [0 1 2 3]     // data
[3 0 1 2 3 0 1] // padded and looped data

 · o-o-o-o-· ·
[0 3 3 3 3 0 0] // line segments (no start or end)

The extra points are needed to draw a continuous loop. This connects each point with the next. We also need the vertex before and after each vertex used, for joins/tangents.

Start/end points

Some layers like <ArrowLayer> support special end-point handling, e.g. to add an arrow head at start and/or end. For this there is a start and end prop that takes an (item) => boolean just like loop.

By passing getArrowSegments to <Data>'s segments prop, the necessary derived fields are auto-generated:

return (
  <Data
    schema={schema}
    data={data}
    segments={getArrowSegments}
    end={true}
  >{
    ({
      positions,
      colors,
      sizes,
      segments, // Added by getArrowSegments
      anchors,  //
      trims,    //
    }) => {
      // ...
      return (
        <ArrowLayer
          positions={positions}
          sizes={sizes}
          colors={colors}
          segments={segments}
          anchors={anchors}
          trims={trims}
        />
      );
    }
  }</Data>

Here, anchors is a data structure describing the end points with decorations, while trims is used to trim lines so they don't stick through arrowheads.

Yeet + Gather

To use sources from multiple data components together, you could nest one inside the other:

return (
  <Data {...props}>{
    ({a, b}) =>
      <Data {...props}>{
        ({c, d}) =>
          <Component a={a} b={b} c={c} d={d} />
        }
      </Data>
  }</Data>
);

But this is pretty unwieldy. It's cleaner to use a <Gather>, and omit the render prop:

import React, { Gather } from '@use-gpu/live';

return (
  <Gather 
    children={[
      <Data {...props} />,
      <Data {...props} />,
    ]}
    // Note: this is an array!
    then={([{a, b}, {c, d}]) => {
      // Render here
    }
  />
);

Each <Data> instance will yeet its sources, and <Gather> will pass on a single array.

menu
format_list_numbered