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
).
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.
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}
/>;
<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 listunwelded
: 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 />;
}}
/>
);
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[][]
.
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 />;
}}
/>
);
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 />;
}}
/>
);
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?]
.
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) => {
// ...
}}
/>
);
To generate data on-the-fly procedurally, set prop expr
to an Emitter
. This is a function that emit
s 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
.
<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) => {
// ...
}}
/>
);
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(...);
// ...
}
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.
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.
<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.
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.
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.
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.
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.
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.