From 05db8601f56e94f6b4809e9f85c2d0c07ff1e4ec Mon Sep 17 00:00:00 2001 From: Szymon Szulc Date: Mon, 19 Oct 2026 02:01:07 +0200 Subject: [PATCH 1/5] fix disarray alignmentOf --- packages/typegpu/src/data/alignmentOf.ts | 10 +-- packages/typegpu/src/data/offsets.ts | 3 +- packages/typegpu/src/data/partialIO.ts | 5 +- packages/typegpu/tests/disarray.test.ts | 64 ++++++++++++++++++- .../typegpu/tests/internal/partialIo.test.ts | 35 ++++++++++ 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/packages/typegpu/src/data/alignmentOf.ts b/packages/typegpu/src/data/alignmentOf.ts index 15fe7df207..33a2939a9e 100644 --- a/packages/typegpu/src/data/alignmentOf.ts +++ b/packages/typegpu/src/data/alignmentOf.ts @@ -55,14 +55,8 @@ function computeAlignment(data: object): number { return alignmentOf(data.elementType); } - if (isUnstruct(data)) { - // A loose struct is aligned to its first property. - const firstProp = Object.values(data.propTypes)[0]; - return firstProp ? (getCustomAlignment(firstProp) ?? 1) : 1; - } - - if (isDisarray(data)) { - return getCustomAlignment(data.elementType) ?? 1; + if (isUnstruct(data) || isDisarray(data)) { + return customAlignmentOf(data); } if (isDecorated(data) || isLooseDecorated(data)) { diff --git a/packages/typegpu/src/data/offsets.ts b/packages/typegpu/src/data/offsets.ts index 0c39354733..eee2977a8b 100644 --- a/packages/typegpu/src/data/offsets.ts +++ b/packages/typegpu/src/data/offsets.ts @@ -1,5 +1,4 @@ import { Measurer } from 'typed-binary'; -import { roundUp } from '../mathUtils.ts'; import alignIO from './alignIO.ts'; import { alignmentOf, customAlignmentOf } from './alignmentOf.ts'; import { isUnstruct, type Unstruct } from './dataTypes.ts'; @@ -49,7 +48,7 @@ export function offsetsForProps( } if (lastEntry) { - lastEntry.padding = roundUp(sizeOf(struct), alignmentOf(struct)) - measurer.size; + lastEntry.padding = sizeOf(struct) - measurer.size; } cachedOffsets.set(struct, offsets); diff --git a/packages/typegpu/src/data/partialIO.ts b/packages/typegpu/src/data/partialIO.ts index df121a857b..8b4278bd62 100644 --- a/packages/typegpu/src/data/partialIO.ts +++ b/packages/typegpu/src/data/partialIO.ts @@ -101,10 +101,7 @@ export function getPatchInstructions( if (isWgslArray(node) || isDisarray(node)) { const arrSchema = node as wgsl.WgslArray; - const elementSize = roundUp( - sizeOf(arrSchema.elementType), - alignmentOf(arrSchema.elementType), - ); + const elementSize = roundUp(sizeOf(arrSchema.elementType), alignmentOf(arrSchema)); const elementPadding = elementSize - sizeOf(arrSchema.elementType); if (ArrayBuffer.isView(value)) { diff --git a/packages/typegpu/tests/disarray.test.ts b/packages/typegpu/tests/disarray.test.ts index de7d785357..b23c328154 100644 --- a/packages/typegpu/tests/disarray.test.ts +++ b/packages/typegpu/tests/disarray.test.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf } from 'vitest'; -import { readFromArrayBuffer, writeToArrayBuffer } from 'typegpu'; +import { readFromArrayBuffer, tgpu, writeToArrayBuffer } from 'typegpu'; import * as d from 'typegpu/data'; import { it } from 'typegpu-testing-utility'; @@ -183,4 +183,66 @@ describe('disarray', () => { d.vec3u(1, 1, 1), ]); }); + + describe('with nested custom alignment', () => { + const AlignedFromFirstField = d.unstruct({ x: d.align(16, d.f32) }); + const AlignedFromNestedFirstField = d.unstruct({ inner: AlignedFromFirstField, y: d.f32 }); + const LocallyAligned = d.unstruct({ a: d.f32, b: d.align(16, d.f32) }); + + it('takes custom alignment from its elements', () => { + expect(d.alignmentOf(d.disarrayOf(AlignedFromFirstField, 2))).toBe(16); + expect(d.alignmentOf(d.disarrayOf(AlignedFromNestedFirstField, 2))).toBe(16); + expect(d.alignmentOf(d.disarrayOf(LocallyAligned, 2))).toBe(1); + expect(d.alignmentOf(d.unstruct({ arr: d.disarrayOf(AlignedFromFirstField, 2) }))).toBe(16); + }); + + it('pads elements to the custom alignment', () => { + expect(d.sizeOf(d.disarrayOf(AlignedFromNestedFirstField, 2))).toBe(32); + expect(d.sizeOf(d.disarrayOf(LocallyAligned, 2))).toBe(40); + }); + + it('pads elements to the custom alignment of a nested unstruct', () => { + const TestArray = d.disarrayOf(AlignedFromNestedFirstField, 2); + const buffer = new ArrayBuffer(d.sizeOf(TestArray)); + const value = [ + { inner: { x: 1 }, y: 2 }, + { inner: { x: 3 }, y: 4 }, + ]; + + writeToArrayBuffer(buffer, TestArray, value); + + expect([...new Float32Array(buffer)]).toStrictEqual([1, 2, 0, 0, 3, 4, 0, 0]); + expect(readFromArrayBuffer(buffer, TestArray)).toStrictEqual(value); + }); + + it('places non-first aligned fields relative to the element', () => { + const TestArray = d.disarrayOf(LocallyAligned, 2); + const buffer = new ArrayBuffer(d.sizeOf(TestArray)); + const value = [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ]; + + writeToArrayBuffer(buffer, TestArray, value); + + expect([...new Float32Array(buffer)]).toStrictEqual([1, 0, 0, 0, 2, 3, 0, 0, 0, 4]); + expect(readFromArrayBuffer(buffer, TestArray)).toStrictEqual(value); + }); + + it('introduces padding before a disarray nested in an unstruct', () => { + const Outer = d.unstruct({ a: d.f32, b: d.disarrayOf(AlignedFromFirstField, 2), c: d.f32 }); + const buffer = new ArrayBuffer(d.sizeOf(Outer)); + const value = { a: 1, b: [{ x: 2 }, { x: 3 }], c: 4 }; + + writeToArrayBuffer(buffer, Outer, value); + + expect([...new Float32Array(buffer)]).toStrictEqual([1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4]); + expect(readFromArrayBuffer(buffer, Outer)).toStrictEqual(value); + }); + + it('uses the custom alignment of nested unstructs as vertex layout stride', () => { + expect(tgpu.vertexLayout(d.disarrayOf(AlignedFromNestedFirstField)).stride).toBe(16); + expect(tgpu.vertexLayout(d.disarrayOf(LocallyAligned)).stride).toBe(20); + }); + }); }); diff --git a/packages/typegpu/tests/internal/partialIo.test.ts b/packages/typegpu/tests/internal/partialIo.test.ts index 0926f37e89..180b1cddb7 100644 --- a/packages/typegpu/tests/internal/partialIo.test.ts +++ b/packages/typegpu/tests/internal/partialIo.test.ts @@ -107,6 +107,14 @@ describe('offsetsForProps', () => { d: { offset: 96, size: 16, padding: 0 }, }); }); + + it('should not add trailing padding to unstructs', () => { + const unstruct = d.unstruct({ x: d.align(16, d.f32) }); + + expect(offsetsForProps(unstruct)).toStrictEqual({ + x: { offset: 0, size: 4, padding: 0 }, + }); + }); }); describe('convertPartialToPatch', () => { @@ -345,4 +353,31 @@ describe('getPatchInstructions', () => { const instructions = getPatchInstructions(struct, { a: 3, b: 4, c: 5 }); expect(instructions.length).toBeGreaterThan(0); }); + + it('should use the disarray stride for disarray elements', () => { + const instructions = getPatchInstructions(d.disarrayOf(d.vec3f, 3), { + 1: d.vec3f(1, 2, 3), + }) as [WriteInstruction]; + + expect(instructions).toHaveLength(1); + expectInstruction(instructions[0], { + start: 12, + length: 12, + expectedData: new Float32Array([1, 2, 3]), + }); + }); + + it('should use the custom alignment of nested unstructs for disarray elements', () => { + const Element = d.unstruct({ inner: d.unstruct({ x: d.align(16, d.f32) }), y: d.f32 }); + const instructions = getPatchInstructions(d.disarrayOf(Element, 2), { + 1: { y: 5 }, + }) as [WriteInstruction]; + + expect(instructions).toHaveLength(1); + expectInstruction(instructions[0], { + start: 20, + length: 4, + expectedData: new Float32Array([5]), + }); + }); }); From 042032c2a787e4ccac50911c8d6ce5cf7dff6d89 Mon Sep 17 00:00:00 2001 From: Szymon Szulc Date: Thu, 22 Oct 2026 13:29:30 +0200 Subject: [PATCH 2/5] fallback writers --- packages/typegpu/src/data/dataIO.ts | 192 ++++++++---------- .../internal/arrayBufferIO.noEval.test.ts | 48 +++++ 2 files changed, 130 insertions(+), 110 deletions(-) diff --git a/packages/typegpu/src/data/dataIO.ts b/packages/typegpu/src/data/dataIO.ts index 0ef7055df9..06a11db3eb 100644 --- a/packages/typegpu/src/data/dataIO.ts +++ b/packages/typegpu/src/data/dataIO.ts @@ -6,10 +6,10 @@ import { type ISerialOutput, } from 'typed-binary'; import type { Infer, InferInput, InferRecord } from '../shared/repr.ts'; -import alignIO from './alignIO.ts'; -import { alignmentOf, customAlignmentOf } from './alignmentOf.ts'; +import { alignmentOf } from './alignmentOf.ts'; import type { AnyConcreteData, AnyData, Disarray, LooseDecorated, Unstruct } from './dataTypes.ts'; import { mat2x2f, mat3x3f, mat4x4f } from './matrix.ts'; +import { offsetsForProps } from './offsets.ts'; import { sizeOf } from './sizeOf.ts'; import { vec2f, @@ -52,6 +52,78 @@ type CompleteDataReaders = { [TType in AnyConcreteData['type']]: DataReader>; }; +function writeStructLike( + output: ISerialOutput, + schema: wgsl.WgslStruct | Unstruct, + value: Record, +) { + // The parent has placed us at our offset + const beginning = output.currentByteOffset; + + for (const [key, { offset }] of Object.entries(offsetsForProps(schema))) { + const property = schema.propTypes[key] as wgsl.BaseData; + output.seekTo(beginning + offset); + writeData(output, property, value[key]); + } + + output.seekTo(beginning + sizeOf(schema)); +} + +function writeArrayLike( + output: ISerialOutput, + schema: wgsl.WgslArray | Disarray, + value: readonly unknown[], +) { + if (schema.elementCount === 0) { + throw new Error('Cannot write using a runtime-sized schema.'); + } + + // The parent has placed us at our offset + const beginning = output.currentByteOffset; + const stride = roundUp(sizeOf(schema.elementType), alignmentOf(schema)); + + for (let i = 0; i < Math.min(schema.elementCount, value.length); i++) { + output.seekTo(beginning + i * stride); + writeData(output, schema.elementType, value[i]); + } + + output.seekTo(beginning + sizeOf(schema)); +} + +function readStructLike(input: ISerialInput, schema: wgsl.WgslStruct | Unstruct) { + // The parent has placed us at our offset + const beginning = input.currentByteOffset; + const result = {} as Record; + + for (const [key, { offset }] of Object.entries(offsetsForProps(schema))) { + input.seekTo(beginning + offset); + result[key] = readData(input, schema.propTypes[key] as wgsl.BaseData); + } + + input.seekTo(beginning + sizeOf(schema)); + return result as InferRecord>; +} + +function readArrayLike(input: ISerialInput, schema: wgsl.WgslArray | Disarray) { + if (schema.elementCount === 0) { + throw new Error('Cannot read using a runtime-sized schema.'); + } + + // The parent has placed us at our offset + const beginning = input.currentByteOffset; + const stride = roundUp(sizeOf(schema.elementType), alignmentOf(schema)); + const elements: unknown[] = []; + + for (let i = 0; i < schema.elementCount; i++) { + input.seekTo(beginning + i * stride); + const value = readData(input, schema.elementType); + elements.push(value); + } + + input.seekTo(beginning + sizeOf(schema)); + return elements as never[]; +} + const dataWriters = { bool() { throw new Error('Booleans are not host-shareable'); @@ -188,32 +260,9 @@ const dataWriters = { } }, - struct(output, schema, value) { - const alignment = alignmentOf(schema); - alignIO(output, alignment); - - for (const [key, property] of Object.entries(schema.propTypes)) { - alignIO(output, alignmentOf(property)); - writeData(output, property, value[key]); - } - - alignIO(output, alignment); - }, - - array(output, schema, value) { - if (schema.elementCount === 0) { - throw new Error('Cannot write using a runtime-sized schema.'); - } + struct: writeStructLike, - const alignment = alignmentOf(schema); - alignIO(output, alignment); - const beginning = output.currentByteOffset; - for (let i = 0; i < Math.min(schema.elementCount, value.length); i++) { - alignIO(output, alignment); - writeData(output, schema.elementType, value[i]); - } - output.seekTo(beginning + sizeOf(schema)); - }, + array: writeArrayLike, ptr() { throw new Error('Pointers are not host-shareable'); @@ -224,9 +273,6 @@ const dataWriters = { }, decorated(output, schema, value) { - const alignment = customAlignmentOf(schema); - alignIO(output, alignment); - const beginning = output.currentByteOffset; dataWriters[(schema.inner as AnyData)?.type]?.(output, schema.inner, value); output.seekTo(beginning + sizeOf(schema)); @@ -420,30 +466,11 @@ const dataWriters = { output.writeUint8(Math.round(value.w * 255)); }, - disarray(output, schema, value) { - const alignment = alignmentOf(schema); + disarray: writeArrayLike, - alignIO(output, alignment); - const beginning = output.currentByteOffset; - for (let i = 0; i < Math.min(schema.elementCount, value.length); i++) { - alignIO(output, alignment); - dataWriters[(schema.elementType as AnyData)?.type]?.(output, schema.elementType, value[i]); - } - - output.seekTo(beginning + sizeOf(schema)); - }, - - unstruct(output, schema, value) { - const propTypes = schema.propTypes; - for (const [key, property] of Object.entries(propTypes)) { - dataWriters[property.type]?.(output, property, value[key]); - } - }, + unstruct: writeStructLike, 'loose-decorated'(output, schema, value) { - const alignment = customAlignmentOf(schema); - alignIO(output, alignment); - const beginning = output.currentByteOffset; const writer = dataWriters[(schema.inner as AnyData)?.type]; writer?.(output, schema.inner, value); @@ -632,39 +659,9 @@ const dataReaders = { ); }, - struct(input: ISerialInput, schema: wgsl.WgslStruct) { - const alignment = alignmentOf(schema); - alignIO(input, alignment); - const result = {} as Record; - - const propTypes = schema.propTypes; - for (const [key, property] of Object.entries(propTypes)) { - alignIO(input, alignmentOf(property)); - result[key] = readData(input, property); - } - - alignIO(input, alignment); - return result as InferRecord>; - }, - - array(input, schema) { - if (schema.elementCount === 0) { - throw new Error('Cannot read using a runtime-sized schema.'); - } + struct: readStructLike, - const alignment = alignmentOf(schema); - const elements: unknown[] = []; - - for (let i = 0; i < schema.elementCount; i++) { - alignIO(input, alignment); - const elementType = schema.elementType as wgsl.AnyWgslData; - const value = readData(input, elementType); - elements.push(value); - } - - alignIO(input, alignment); - return elements as never[]; - }, + array: readArrayLike, ptr() { throw new Error('Pointers are not host-shareable'); @@ -675,9 +672,6 @@ const dataReaders = { }, decorated(input, schema: wgsl.Decorated) { - const alignment = customAlignmentOf(schema); - alignIO(input, alignment); - const beginning = input.currentByteOffset; const value = readData(input, schema.inner); input.seekTo(beginning + sizeOf(schema)); @@ -759,33 +753,11 @@ const dataReaders = { return vec4f(r, g, b, a); }, - unstruct(input, schema: Unstruct) { - const result = {} as Record; + unstruct: readStructLike, - const propTypes = schema.propTypes; - for (const [key, property] of Object.entries(propTypes)) { - result[key] = readData(input, property); - } - - return result as InferRecord>; - }, - - disarray(input, schema: Disarray) { - const alignment = alignmentOf(schema); - const elements: unknown[] = []; - - for (let i = 0; i < schema.elementCount; i++) { - alignIO(input, alignment); - elements.push(readData(input, schema.elementType)); - } - - alignIO(input, alignment); - return elements; - }, + disarray: readArrayLike, 'loose-decorated'(input, schema: LooseDecorated) { - alignIO(input, customAlignmentOf(schema)); - const beginning = input.currentByteOffset; const value = readData(input, schema.inner); input.seekTo(beginning + sizeOf(schema)); diff --git a/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts b/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts index 8cd14d7bb5..ea15cf8363 100644 --- a/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts +++ b/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts @@ -135,4 +135,52 @@ describe('arrayBufferIO', () => { 1, 2, 0, 0, 3, 0, 0, 0, 4, 5, 0, 0, 6, 0, 0, 0, ]); }); + + describe('with nested custom alignment', () => { + const AlignedFromFirstField = d.unstruct({ x: d.align(16, d.f32) }); + const AlignedFromNestedFirstField = d.unstruct({ inner: AlignedFromFirstField, y: d.f32 }); + const LocallyAligned = d.unstruct({ a: d.f32, b: d.align(16, d.f32) }); + + it('pads elements to the custom alignment of a nested unstruct', () => { + const TestArray = d.disarrayOf(AlignedFromNestedFirstField, 2); + const buffer = new ArrayBuffer(d.sizeOf(TestArray)); + + writeToArrayBuffer(buffer, TestArray, [ + { inner: { x: 1 }, y: 2 }, + { inner: { x: 3 }, y: 4 }, + ]); + + expect([...new Float32Array(buffer)]).toStrictEqual([1, 2, 0, 0, 3, 4, 0, 0]); + }); + + it('places non-first aligned fields relative to the element', () => { + const TestArray = d.disarrayOf(LocallyAligned, 2); + const buffer = new ArrayBuffer(d.sizeOf(TestArray)); + + writeToArrayBuffer(buffer, TestArray, [ + { a: 1, b: 2 }, + { a: 3, b: 4 }, + ]); + + expect([...new Float32Array(buffer)]).toStrictEqual([1, 0, 0, 0, 2, 3, 0, 0, 0, 4]); + }); + + it('introduces padding before a disarray nested in an unstruct', () => { + const Outer = d.unstruct({ a: d.f32, b: d.disarrayOf(AlignedFromFirstField, 2), c: d.f32 }); + const buffer = new ArrayBuffer(d.sizeOf(Outer)); + + writeToArrayBuffer(buffer, Outer, { a: 1, b: [{ x: 2 }, { x: 3 }], c: 4 }); + + expect([...new Float32Array(buffer)]).toStrictEqual([1, 0, 0, 0, 2, 0, 0, 0, 3, 0, 0, 0, 4]); + }); + + it('does not align wgsl structs packed into an unstruct', () => { + const Outer = d.unstruct({ a: d.u32, s: d.struct({ v: d.vec3u, w: d.u32 }) }); + const buffer = new ArrayBuffer(d.sizeOf(Outer)); + + writeToArrayBuffer(buffer, Outer, { a: 1, s: { v: d.vec3u(2, 3, 4), w: 5 } }); + + expect([...new Uint32Array(buffer)]).toStrictEqual([1, 2, 3, 4, 5]); + }); + }); }); From d8034596aa67ab15dbe13f990bfeecce06f778be Mon Sep 17 00:00:00 2001 From: Szymon Szulc Date: Thu, 22 Oct 2026 13:33:30 +0200 Subject: [PATCH 3/5] readback test --- packages/typegpu/tests/unstruct.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/typegpu/tests/unstruct.test.ts b/packages/typegpu/tests/unstruct.test.ts index 3bfb7c428c..f5c1263275 100644 --- a/packages/typegpu/tests/unstruct.test.ts +++ b/packages/typegpu/tests/unstruct.test.ts @@ -163,6 +163,18 @@ describe('d.unstruct', () => { expect(data.c.b.y).toBe(5); }); + it('reads wgsl structs packed into an unstruct', () => { + const s = d.unstruct({ a: d.u32, s: d.struct({ v: d.vec3u, w: d.u32 }) }); + const buffer = new ArrayBuffer(d.sizeOf(s)); + + new Uint32Array(buffer).set([1, 2, 3, 4, 5]); + + expect(readFromArrayBuffer(buffer, s)).toStrictEqual({ + a: 1, + s: { v: d.vec3u(2, 3, 4), w: 5 }, + }); + }); + it('can be custom aligned and behaves properly', () => { const s = d.align( 16, From fabf065857f9fd548f374cc3db99cfa2c90c1aae Mon Sep 17 00:00:00 2001 From: Szymon Szulc Date: Thu, 22 Oct 2026 16:35:42 +0200 Subject: [PATCH 4/5] fix types --- packages/typegpu/src/data/dataIO.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typegpu/src/data/dataIO.ts b/packages/typegpu/src/data/dataIO.ts index 06a11db3eb..1d4a75cce7 100644 --- a/packages/typegpu/src/data/dataIO.ts +++ b/packages/typegpu/src/data/dataIO.ts @@ -55,7 +55,7 @@ type CompleteDataReaders = { function writeStructLike( output: ISerialOutput, schema: wgsl.WgslStruct | Unstruct, - value: Record, + value: InferInput, ) { // The parent has placed us at our offset const beginning = output.currentByteOffset; @@ -72,7 +72,7 @@ function writeStructLike( function writeArrayLike( output: ISerialOutput, schema: wgsl.WgslArray | Disarray, - value: readonly unknown[], + value: InferInput, ) { if (schema.elementCount === 0) { throw new Error('Cannot write using a runtime-sized schema.'); From 40757428f1c864fa9ed1a18b789b666e8d99a034 Mon Sep 17 00:00:00 2001 From: Szymon Szulc Date: Wed, 28 Oct 2026 00:15:06 +0100 Subject: [PATCH 5/5] custom first field of struct alignment + vertex layout fix --- .../src/core/vertexLayout/vertexLayout.ts | 51 ++++++------------- packages/typegpu/src/data/alignmentOf.ts | 3 +- packages/typegpu/tests/disarray.test.ts | 13 +++++ .../internal/arrayBufferIO.noEval.test.ts | 10 ++++ packages/typegpu/tests/unstruct.test.ts | 34 ++++++++++++- packages/typegpu/tests/vertexLayout.test.ts | 42 +++++++++++++++ 6 files changed, 114 insertions(+), 39 deletions(-) diff --git a/packages/typegpu/src/core/vertexLayout/vertexLayout.ts b/packages/typegpu/src/core/vertexLayout/vertexLayout.ts index 769d142011..9e4cda5669 100644 --- a/packages/typegpu/src/core/vertexLayout/vertexLayout.ts +++ b/packages/typegpu/src/core/vertexLayout/vertexLayout.ts @@ -1,6 +1,7 @@ -import { alignmentOf, customAlignmentOf } from '../../data/alignmentOf.ts'; +import { alignmentOf } from '../../data/alignmentOf.ts'; import type { Disarray } from '../../data/dataTypes.ts'; import { getCustomLocation, isLooseDecorated, isUnstruct } from '../../data/dataTypes.ts'; +import { type OffsetInfo, offsetsForProps } from '../../data/offsets.ts'; import { sizeOf } from '../../data/sizeOf.ts'; import type { BaseData, WgslArray } from '../../data/wgslTypes.ts'; import { isDecorated, isWgslStruct } from '../../data/wgslTypes.ts'; @@ -73,45 +74,23 @@ function dataToContainedAttribs { - memberOffset = roundUp(memberOffset, alignmentOf(value)); - const attrib = [ - key, - dataToContainedAttribs(layout, value, memberOffset, customLocationMap, key), - ]; - memberOffset += sizeOf(value); - return attrib; - }), - ) as DataToContainedAttribs; + // passing offset unchanged because the parent already applied alignment + return dataToContainedAttribs(layout, data.inner, offset, customLocationMap); } - if (isUnstruct(data)) { - let memberOffset = offset; - - const propTypes = data.propTypes; + if (isWgslStruct(data) || isUnstruct(data)) { + const offsets = offsetsForProps(data); return Object.fromEntries( - Object.entries(propTypes).map(([key, value]) => { - memberOffset = roundUp(memberOffset, customAlignmentOf(value)); - const attrib = [ + Object.entries(data.propTypes).map(([key, value]) => [ + key, + dataToContainedAttribs( + layout, + value, + offset + (offsets[key] as OffsetInfo).offset, + customLocationMap, key, - dataToContainedAttribs(layout, value, memberOffset, customLocationMap, key), - ]; - memberOffset += sizeOf(value); - return attrib; - }), + ), + ]), ) as DataToContainedAttribs; } diff --git a/packages/typegpu/src/data/alignmentOf.ts b/packages/typegpu/src/data/alignmentOf.ts index 33a2939a9e..e60e4e4d09 100644 --- a/packages/typegpu/src/data/alignmentOf.ts +++ b/packages/typegpu/src/data/alignmentOf.ts @@ -71,8 +71,7 @@ function computeAlignment(data: object): number { } function computeCustomAlignment(data: BaseData): number { - if (isUnstruct(data)) { - // A loose struct is aligned to its first property. + if (isUnstruct(data) || isWgslStruct(data)) { const firstProp = Object.values(data.propTypes)[0]; return firstProp ? customAlignmentOf(firstProp) : 1; } diff --git a/packages/typegpu/tests/disarray.test.ts b/packages/typegpu/tests/disarray.test.ts index b23c328154..4a6c4b91a0 100644 --- a/packages/typegpu/tests/disarray.test.ts +++ b/packages/typegpu/tests/disarray.test.ts @@ -196,6 +196,19 @@ describe('disarray', () => { expect(d.alignmentOf(d.unstruct({ arr: d.disarrayOf(AlignedFromFirstField, 2) }))).toBe(16); }); + it("inherits only the first struct field's explicit alignment through nesting", () => { + const Explicit = d.struct({ x: d.align(16, d.f32) }); + const Implicit = d.struct({ x: d.vec4f }); + const NonFirst = d.struct({ x: d.f32, y: d.align(16, d.f32) }); + const Nested = d.struct({ inner: Explicit, y: d.f32 }); + + expect(d.alignmentOf(d.disarrayOf(Explicit, 2))).toBe(16); + expect(d.alignmentOf(d.disarrayOf(Implicit, 2))).toBe(1); + expect(d.alignmentOf(d.disarrayOf(NonFirst, 2))).toBe(1); + expect(d.alignmentOf(d.disarrayOf(Nested, 2))).toBe(16); + expect(d.alignmentOf(d.unstruct({ arr: d.disarrayOf(Explicit, 2) }))).toBe(16); + }); + it('pads elements to the custom alignment', () => { expect(d.sizeOf(d.disarrayOf(AlignedFromNestedFirstField, 2))).toBe(32); expect(d.sizeOf(d.disarrayOf(LocallyAligned, 2))).toBe(40); diff --git a/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts b/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts index ea15cf8363..450c23e2dc 100644 --- a/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts +++ b/packages/typegpu/tests/internal/arrayBufferIO.noEval.test.ts @@ -182,5 +182,15 @@ describe('arrayBufferIO', () => { expect([...new Uint32Array(buffer)]).toStrictEqual([1, 2, 3, 4, 5]); }); + + it("aligns a nested struct to its first field's explicit alignment", () => { + const Inner = d.struct({ v: d.align(16, d.vec2u), w: d.u32 }); + const Outer = d.unstruct({ a: d.u32, inner: Inner, z: d.u32 }); + const buffer = new ArrayBuffer(d.sizeOf(Outer)); + + writeToArrayBuffer(buffer, Outer, { a: 1, inner: { v: d.vec2u(2, 3), w: 4 }, z: 5 }); + + expect([...new Uint32Array(buffer)]).toStrictEqual([1, 0, 0, 0, 2, 3, 4, 0, 5]); + }); }); }); diff --git a/packages/typegpu/tests/unstruct.test.ts b/packages/typegpu/tests/unstruct.test.ts index f5c1263275..5f05f7f7a3 100644 --- a/packages/typegpu/tests/unstruct.test.ts +++ b/packages/typegpu/tests/unstruct.test.ts @@ -163,7 +163,7 @@ describe('d.unstruct', () => { expect(data.c.b.y).toBe(5); }); - it('reads wgsl structs packed into an unstruct', () => { + it('reads wgsl contiguous structs packed into an unstruct', () => { const s = d.unstruct({ a: d.u32, s: d.struct({ v: d.vec3u, w: d.u32 }) }); const buffer = new ArrayBuffer(d.sizeOf(s)); @@ -175,6 +175,38 @@ describe('d.unstruct', () => { }); }); + it('reads wgsl structs with trailing padding packed into an unstruct', () => { + const s = d.unstruct({ a: d.u32, s: d.struct({ v: d.vec3u }), w: d.u32 }); + const buffer = new ArrayBuffer(d.sizeOf(s)); + + new Uint32Array(buffer).set([1, 2, 3, 4, 0, 5]); + + expect(readFromArrayBuffer(buffer, s)).toStrictEqual({ + a: 1, + s: { v: d.vec3u(2, 3, 4) }, + w: 5, + }); + }); + + it("respects a nested struct's first field's explicit alignment", () => { + expect( + d.alignmentOf(d.unstruct({ inner: d.struct({ v: d.align(16, d.vec2u), w: d.u32 }) })), + ).toBe(16); + }); + + it("aligns a nested struct to its first field's explicit alignment when writing and reading", () => { + const Inner = d.struct({ v: d.align(16, d.vec2u), w: d.u32 }); + const Outer = d.unstruct({ a: d.u32, inner: Inner, z: d.u32 }); + + const buffer = new ArrayBuffer(d.sizeOf(Outer)); + const value = { a: 1, inner: { v: d.vec2u(2, 3), w: 4 }, z: 5 }; + + writeToArrayBuffer(buffer, Outer, value); + + expect([...new Uint32Array(buffer)]).toStrictEqual([1, 0, 0, 0, 2, 3, 4, 0, 5]); + expect(readFromArrayBuffer(buffer, Outer)).toStrictEqual(value); + }); + it('can be custom aligned and behaves properly', () => { const s = d.align( 16, diff --git a/packages/typegpu/tests/vertexLayout.test.ts b/packages/typegpu/tests/vertexLayout.test.ts index 6a8cc73fc4..487ed28d91 100644 --- a/packages/typegpu/tests/vertexLayout.test.ts +++ b/packages/typegpu/tests/vertexLayout.test.ts @@ -54,6 +54,48 @@ describe('tgpu.vertexLayout', () => { }); }); + it('preserves struct-relative alignment inside an unstruct but does not align starting offset', () => { + const Geometry = d.struct({ + position: d.vec2f, + color: d.vec3f, + }); + const Vertex = d.unstruct({ + tint: d.unorm8x4, + geometry: Geometry, + }); + const layout = tgpu.vertexLayout(d.disarrayOf(Vertex)); + + expect(layout.stride).toBe(36); + expect(layout.attrib).toMatchObject({ + tint: { format: 'unorm8x4', offset: 0 }, + geometry: { + position: { format: 'float32x2', offset: 4 }, + color: { format: 'float32x3', offset: 20 }, + }, + }); + }); + + it("aligns a nested struct to its first field's explicit alignment", () => { + const Geometry = d.struct({ + position: d.align(16, d.vec2f), + color: d.vec3f, + }); + const Vertex = d.unstruct({ + tint: d.unorm8x4, + geometry: Geometry, + }); + const layout = tgpu.vertexLayout(d.disarrayOf(Vertex)); + + expect(layout.stride).toBe(48); + expect(layout.attrib).toMatchObject({ + tint: { format: 'unorm8x4', offset: 0 }, + geometry: { + position: { format: 'float32x2', offset: 16 }, + color: { format: 'float32x3', offset: 32 }, + }, + }); + }); + it('throws when trying to use type that has no attribute representation', () => { expect(() => tgpu.vertexLayout(d.disarrayOf(d.vec3h))).toThrow(); });