Skip to content

Commit bf5f903

Browse files
authored
impr: Add a common.writeSoA(buffer, data) for compatible buffers and extend the initial data field for more flexibility (#2320)
1 parent eae2e8a commit bf5f903

File tree

8 files changed

+803
-34
lines changed

8 files changed

+803
-34
lines changed

apps/typegpu-docs/src/content/docs/fundamentals/buffers.mdx

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ const Particle = d.struct({
2929
});
3030

3131
// Utility for creating a random particle
32-
function createParticle(): d.Infer<typeof Particle> {
32+
function createParticle(): d.InferInput<typeof Particle> {
3333
return {
3434
position: d.vec3f(Math.random(), 2, Math.random()),
3535
velocity: d.vec3f(0, 9.8, 0),
@@ -160,6 +160,26 @@ const buffer2 = root.createBuffer(d.arrayOf(d.vec3f, 2), [
160160
]);
161161
```
162162

163+
For cases where a plain typed value is not enough, you can also pass an initializer callback.
164+
It receives the newly created typed buffer while it is still mapped, so you can populate it with multiple writes or helper utilities before the first upload.
165+
166+
```ts twoslash
167+
import tgpu, { d } from 'typegpu';
168+
169+
const root = await tgpu.init();
170+
// ---cut---
171+
const Schema = d.arrayOf(d.u32, 6);
172+
const firstChunk = d.memoryLayoutOf(Schema, (a) => a[1]);
173+
const secondChunk = d.memoryLayoutOf(Schema, (a) => a[4]);
174+
175+
const buffer = root.createBuffer(Schema, (mappedBuffer) => {
176+
mappedBuffer.write([10, 20], { startOffset: firstChunk.offset });
177+
mappedBuffer.write([30, 40], { startOffset: secondChunk.offset });
178+
});
179+
```
180+
181+
For buffers whose CPU-side data already lives in separate per-field arrays, see the [SoA write section](#writing-struct-of-arrays-soa-data).
182+
163183
### Using an existing buffer
164184

165185
You can also create a buffer using an existing WebGPU buffer. This is useful when you have existing logic but want to introduce type-safe data operations.
@@ -383,6 +403,91 @@ planetBuffer.writePartial({
383403
});
384404
```
385405

406+
### Writing struct-of-arrays (SoA) data
407+
408+
When the buffer schema is an `array<struct<...>>`, you can write the data in a struct-of-arrays form with `writeSoA` from `typegpu/common`.
409+
This is useful when your CPU-side data is already stored per-field, such as simulation attributes kept in separate typed arrays.
410+
411+
```ts twoslash
412+
import tgpu, { d, common } from 'typegpu';
413+
414+
const root = await tgpu.init();
415+
// ---cut---
416+
const Particle = d.struct({
417+
pos: d.vec3f,
418+
vel: d.f32,
419+
});
420+
421+
const particleBuffer = root.createBuffer(d.arrayOf(Particle, 2));
422+
423+
common.writeSoA(particleBuffer, {
424+
pos: new Float32Array([
425+
1, 2, 3,
426+
4, 5, 6,
427+
]),
428+
vel: new Float32Array([10, 20]),
429+
});
430+
```
431+
432+
`writeSoA` converts those field-wise arrays into the buffer's GPU array-of-structs layout before uploading.
433+
For the example above, the resulting bytes match two `Particle` structs laid out in memory, including the padding required by `vec3f`.
434+
435+
:::note[Supported field shapes]
436+
SoA writes work for arrays of structs whose fields are:
437+
- scalars,
438+
- vectors,
439+
- matrices,
440+
- fixed-size arrays of scalars, vectors, or matrices.
441+
442+
Nested struct fields are not supported.
443+
:::
444+
445+
:::tip[Packed input, padded output]
446+
Unlike `.write()` with a raw `TypedArray` or `ArrayBuffer`, `writeSoA` expects each field in its natural packed form.
447+
For example, a `vec3f` field should be provided as 3 floats per element, not 4, and a `mat3x3f` field should be provided as 9 floats per matrix, not 12.
448+
TypeGPU inserts the required WGSL padding while scattering the data into the target buffer.
449+
:::
450+
451+
You can also restrict the write to a slice of the destination buffer:
452+
453+
```ts twoslash
454+
import tgpu, { d, common } from 'typegpu';
455+
456+
const root = await tgpu.init();
457+
// ---cut---
458+
const Entry = d.struct({
459+
id: d.u32,
460+
values: d.arrayOf(d.vec3f, 2),
461+
});
462+
463+
const schema = d.arrayOf(Entry, 4);
464+
const buffer = root.createBuffer(schema);
465+
466+
const start = d.memoryLayoutOf(schema, (a) => a[1]);
467+
const end = d.memoryLayoutOf(schema, (a) => a[3]);
468+
469+
common.writeSoA(
470+
buffer,
471+
{
472+
id: new Uint32Array([30, 40]),
473+
values: new Float32Array([
474+
1, 2, 3,
475+
4, 5, 6,
476+
7, 8, 9,
477+
10, 11, 12,
478+
]),
479+
},
480+
{
481+
startOffset: start.offset,
482+
endOffset: end.offset,
483+
},
484+
);
485+
```
486+
487+
If `endOffset` is omitted, TypeGPU infers the written range from the provided field arrays and stops at the shortest complete element count implied by the data.
488+
`startOffset` is still byte-based and should point to the start of a struct element, so `d.memoryLayoutOf` is the safest way to compute it.
489+
All struct fields must be present in the SoA object.
490+
386491
### Copying
387492

388493
There's also an option to copy value from another typed buffer using the `.copyFrom(buffer)` method,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
// NOTE: This is a barrel file, internal files should not import things from this file
22

33
export { fullScreenTriangle } from './fullScreenTriangle.ts';
4+
export { writeSoA } from './writeSoA.ts';
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import { invariant } from '../errors.ts';
2+
import { roundUp } from '../mathUtils.ts';
3+
import { alignmentOf } from '../data/alignmentOf.ts';
4+
import { offsetsForProps } from '../data/offsets.ts';
5+
import { sizeOf } from '../data/sizeOf.ts';
6+
import type { BaseData, TypedArrayFor, WgslArray, WgslStruct } from '../data/wgslTypes.ts';
7+
import { isMat, isMat2x2f, isMat3x3f, isWgslArray } from '../data/wgslTypes.ts';
8+
import type { BufferWriteOptions, TgpuBuffer } from '../core/buffer/buffer.ts';
9+
import type { Prettify } from '../shared/utilityTypes.ts';
10+
11+
type UnwrapWgslArray<T> = T extends WgslArray<infer U> ? UnwrapWgslArray<U> : T;
12+
type PackedSoAInputFor<T> = TypedArrayFor<UnwrapWgslArray<T>>;
13+
14+
type SoAFieldsFor<T extends Record<string, BaseData>> = {
15+
[K in keyof T as [PackedSoAInputFor<T[K]>] extends [never] ? never : K]: PackedSoAInputFor<T[K]>;
16+
};
17+
18+
type SoAInputFor<T extends Record<string, BaseData>> = [keyof T] extends [keyof SoAFieldsFor<T>]
19+
? Prettify<SoAFieldsFor<T>>
20+
: never;
21+
22+
function getPackedMatrixLayout(schema: BaseData) {
23+
if (!isMat(schema)) {
24+
return undefined;
25+
}
26+
27+
const dim = isMat3x3f(schema) ? 3 : isMat2x2f(schema) ? 2 : 4;
28+
const packedColumnSize = dim * 4;
29+
30+
return {
31+
dim,
32+
packedColumnSize,
33+
packedSize: dim * packedColumnSize,
34+
} as const;
35+
}
36+
37+
function packedSizeOf(schema: BaseData): number {
38+
const matrixLayout = getPackedMatrixLayout(schema);
39+
if (matrixLayout) {
40+
return matrixLayout.packedSize;
41+
}
42+
43+
if (isWgslArray(schema)) {
44+
return schema.elementCount * packedSizeOf(schema.elementType);
45+
}
46+
47+
return sizeOf(schema);
48+
}
49+
50+
function inferSoAElementCount(
51+
arraySchema: WgslArray,
52+
soaData: Record<string, ArrayBufferView>,
53+
): number | undefined {
54+
const structSchema = arraySchema.elementType as WgslStruct;
55+
let inferredCount: number | undefined;
56+
57+
for (const key in soaData) {
58+
const srcArray = soaData[key];
59+
const fieldSchema = structSchema.propTypes[key];
60+
if (srcArray === undefined || fieldSchema === undefined) {
61+
continue;
62+
}
63+
64+
const fieldPackedSize = packedSizeOf(fieldSchema);
65+
if (fieldPackedSize === 0) {
66+
continue;
67+
}
68+
69+
const fieldElementCount = Math.floor(srcArray.byteLength / fieldPackedSize);
70+
inferredCount =
71+
inferredCount === undefined ? fieldElementCount : Math.min(inferredCount, fieldElementCount);
72+
}
73+
74+
return inferredCount;
75+
}
76+
77+
function computeSoAByteLength(
78+
arraySchema: WgslArray,
79+
soaData: Record<string, ArrayBufferView>,
80+
): number | undefined {
81+
const elementCount = inferSoAElementCount(arraySchema, soaData);
82+
if (elementCount === undefined) {
83+
return undefined;
84+
}
85+
const elementStride = roundUp(
86+
sizeOf(arraySchema.elementType),
87+
alignmentOf(arraySchema.elementType),
88+
);
89+
return elementCount * elementStride;
90+
}
91+
92+
function writePackedValue(
93+
target: Uint8Array,
94+
schema: BaseData,
95+
srcBytes: Uint8Array,
96+
dstOffset: number,
97+
srcOffset: number,
98+
): void {
99+
const matrixLayout = getPackedMatrixLayout(schema);
100+
if (matrixLayout) {
101+
const gpuColumnStride = roundUp(matrixLayout.packedColumnSize, alignmentOf(schema));
102+
103+
for (let col = 0; col < matrixLayout.dim; col++) {
104+
target.set(
105+
srcBytes.subarray(
106+
srcOffset + col * matrixLayout.packedColumnSize,
107+
srcOffset + col * matrixLayout.packedColumnSize + matrixLayout.packedColumnSize,
108+
),
109+
dstOffset + col * gpuColumnStride,
110+
);
111+
}
112+
113+
return;
114+
}
115+
116+
if (isWgslArray(schema)) {
117+
const packedElementSize = packedSizeOf(schema.elementType);
118+
const gpuElementStride = roundUp(sizeOf(schema.elementType), alignmentOf(schema.elementType));
119+
120+
for (let i = 0; i < schema.elementCount; i++) {
121+
writePackedValue(
122+
target,
123+
schema.elementType,
124+
srcBytes,
125+
dstOffset + i * gpuElementStride,
126+
srcOffset + i * packedElementSize,
127+
);
128+
}
129+
130+
return;
131+
}
132+
133+
target.set(srcBytes.subarray(srcOffset, srcOffset + sizeOf(schema)), dstOffset);
134+
}
135+
136+
function scatterSoA(
137+
target: Uint8Array,
138+
arraySchema: WgslArray,
139+
soaData: Record<string, ArrayBufferView>,
140+
startOffset: number,
141+
endOffset: number,
142+
): void {
143+
const structSchema = arraySchema.elementType as WgslStruct;
144+
const offsets = offsetsForProps(structSchema);
145+
const elementStride = roundUp(sizeOf(structSchema), alignmentOf(structSchema));
146+
invariant(
147+
startOffset % elementStride === 0,
148+
`startOffset (${startOffset}) must be aligned to the element stride (${elementStride})`,
149+
);
150+
const startElement = Math.floor(startOffset / elementStride);
151+
const endElement = Math.min(arraySchema.elementCount, Math.ceil(endOffset / elementStride));
152+
const elementCount = Math.max(0, endElement - startElement);
153+
154+
for (const key in structSchema.propTypes) {
155+
const fieldSchema = structSchema.propTypes[key];
156+
if (fieldSchema === undefined) {
157+
continue;
158+
}
159+
const srcArray = soaData[key];
160+
invariant(srcArray !== undefined, `Missing SoA data for field '${key}'`);
161+
162+
const fieldOffset = offsets[key]?.offset;
163+
invariant(fieldOffset !== undefined, `Field ${key} not found in struct schema`);
164+
const srcBytes = new Uint8Array(srcArray.buffer, srcArray.byteOffset, srcArray.byteLength);
165+
166+
const packedFieldSize = packedSizeOf(fieldSchema);
167+
for (let i = 0; i < elementCount; i++) {
168+
writePackedValue(
169+
target,
170+
fieldSchema,
171+
srcBytes,
172+
(startElement + i) * elementStride + fieldOffset,
173+
i * packedFieldSize,
174+
);
175+
}
176+
}
177+
}
178+
179+
export function writeSoA<TProps extends Record<string, BaseData>>(
180+
buffer: TgpuBuffer<WgslArray<WgslStruct<TProps>>>,
181+
data: SoAInputFor<TProps>,
182+
options?: BufferWriteOptions,
183+
): void {
184+
const arrayBuffer = buffer.arrayBuffer;
185+
const startOffset = options?.startOffset ?? 0;
186+
const bufferSize = sizeOf(buffer.dataType);
187+
const naturalSize = computeSoAByteLength(
188+
buffer.dataType,
189+
data as Record<string, ArrayBufferView>,
190+
);
191+
const endOffset =
192+
options?.endOffset ??
193+
(naturalSize === undefined ? bufferSize : Math.min(startOffset + naturalSize, bufferSize));
194+
195+
scatterSoA(
196+
new Uint8Array(arrayBuffer),
197+
buffer.dataType,
198+
data as Record<string, ArrayBufferView>,
199+
startOffset,
200+
endOffset,
201+
);
202+
buffer.write(arrayBuffer, { startOffset, endOffset });
203+
}
204+
205+
export namespace writeSoA {
206+
export type InputFor<TProps extends Record<string, BaseData>> = SoAInputFor<TProps>;
207+
}

0 commit comments

Comments
 (0)