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 as [T, Accessor, AccessorType?].

const fields = [
  ['vec3<f32>', 'position'],
  ['vec3<f32>', 'color'],
  ['f32',       'size'],
];

Accessors can also be functions, for computed props. The above is equivalent to:

const fields = [
  ['vec3<f32>', item => item.position],
  ['vec3<f32>', item => item.color],
  ['f32',       item => item.size],
];

We pass data and fields to <Data>:

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

return (
  <Data
    fields={fields}
    data={data}
    render=((
      positions: StorageSource,
      colors: StorageSource,
      sizes: StorageSource,
    ) => {
      // ...
      return <Component />;
    }
  />
);

The data is converted to a struct-of-arrays, provided as 3 separate StorageSource GPU buffers. They can be rendered with layers like <PointLayer> in the render prop:

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

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

Composite Array-of-Structs

<CompositeData> allows you to use variable-length 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 { CompositeData } from '@use-gpu/workbench';

const fields = [
  ['array<vec3<f32>>', item => item.positions],
  ['vec3<f32>',        item => item.color],
  ['f32',              item => item.size],
];

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.

<CompositeData> supports an AccessorType specified as [format, accessor, accessorType]:

  • index: indices are adjusted as data is concatenated into one big list
  • unwelded: attribute is associated with an index rather than a vertex

<CompositeData> works the same as <Data> otherwise:

return (
  <CompositeData
    fields={fields}
    data={data}
    render={(
      positions: StorageSource,
      colors: StorageSource,
      sizes: StorageSource,
    ) => {
      // ...
      return <Component />;
    }}
  />
);

Struct-of-Arrays (loose)

If your data is already arranged with one array per field, you can pass it directly, using the data itself as the Accessor.

This works best with TypedArray, but you can also just use number[]:

// 4 data points, fully expanded
const positions = [
  0, 0, 0,
  0, 1, 0,
  1, 1, 0,
  1, 0, 0,
];
const colors = [
  0.3, 0.4, 0.5,
  0.3, 0.4, 0.5,
  0.3, 0.4, 0.5,
  0.3, 0.4, 0.5,
];
const sizes = [
  5,
  2,
  3,
  2,
];

const fields = [
  ['vec3<f32>', positions, 'position'],
  ['vec3<f32>', colors],
  ['f32',       sizes],
];

In this case, there is only fields, no data:

return (
  <Data
    fields={fields}
    render={(
      positions: StorageSource,
      colors: StorageSource,
      sizes: StorageSource,
    ) => {
      // ...
      return <Component />;
    }}
  />
);

Passing number arrays directly also works with <CompositeData>. In that case, an array<vecN<..>> must be passed as a nested number array number[][].

Struct-of-Arrays (record)

As an alternative to a loose Struct-of-Arrays, you can use <GeometryData> to load a single MeshGeometry object and obtain a dictionary of StorageSources. 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}
    render={(mesh: Record<string, StorageSource>) => {
      // ...
      return <Component />;
    }}
  />
);

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_FIELDS = [
  ['vec4<f32>', 'position', 'position'],
  ['vec4<f32>', 'normal'],
  ['vec4<f32>', 'color'],
  ['vec2<f32>', 'uv'],
];

return (
  <InterleavedData
    fields={MESH_FIELDS}
    data={vertexArray}
    render={(
      positions: StorageSource,
      normals: StorageSource,
      colors: StorageSource,
      uvs: StorageSource,
    ) => {
      // ...
      return <Component />;
    }}
  />
);

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}
    render={(positions: StorageSource) => {
      // ...
      return <Component />;
    }}
  />
);

To unpack a flat array into a multidimensional one, use ArrayData.

return (
  <ArrayData
    format='vec3<f32>'
    data={positions}
    size={[2, 2]}
    render={(positions: StorageSource) => {
      // ...
      return <Component />;
    }}
  />
);

Prop size is a number[], i.e. [width, height?, depth?, layers?].

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, set prop expr to 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) => {
      // ...
    }}
  />
);

Array emitter

Use <ArrayData> to generate multi-dimensional arrays, where prop size is a number[], i.e. [width, height?, depth?, layers?].

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

E.g. a 2D array:

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

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

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 <SampledData> to produce a multi-dimensional array sampled on a given range [min, max][]:

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

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

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
    }}
    render={(positions: ShaderSource, colors: ShaderSource) => {
      // ...
    }}
  />
);

These sources are a LambdaSource rather than a StorageSource. To abstract over the difference, we use the union type ShaderSource.

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 fields 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 fields 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

Each StorageSource is a GPU array of a single data type, e.g. vec4<f32>. 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 <LineSegments> to <CompositeData> via on. This will cause an additional source to appear:

return (
  <CompositeData
    fields={fields}
    data={data}
    on={<LineSegments />}
    render={(
      positions: StorageSource,
      colors: StorageSource,
      sizes: StorageSource,
      segments: StorageSource, // Added by <LineSegments>
    ) => {
      // ...
      return <Component />;
    }}
  />
);

You can also draw closed loops of data, by passing a loop prop to <CompositeData>. 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. When combined with on <ArrowSegments>, you will get:

  // render:
  (
    positions: StorageSource,
    colors: StorageSource,
    sizes: StorageSource,
    segments: StorageSource, // Added by <ArrowSegments>
    anchors: StorageSource,  //
    trims: StorageSource,    //
  ) => {
    // ...
    return (
      <ArrowLayer
        positions={positions}
        sizes={sizes}
        colors={colors}
        segments={segments}
        anchors={anchors}
        trims={trims}
      />
    );
  }

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}
    render={(a, b) =>
      <Data
        {...props}
        render={(c, d) =>
          <Component a={a} b={b} c={c} d={d} />
        }
     />
    }
  />
);

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