diff --git a/packages/typegpu-testing-utility/src/extendedIt.ts b/packages/typegpu-testing-utility/src/extendedIt.ts index 14357a3f29..00ba17e02c 100644 --- a/packages/typegpu-testing-utility/src/extendedIt.ts +++ b/packages/typegpu-testing-utility/src/extendedIt.ts @@ -23,6 +23,8 @@ export const it = base }, draw: vi.fn(), drawIndexed: vi.fn(), + drawIndirect: vi.fn(), + drawIndexedIndirect: vi.fn(), end: vi.fn(), setBindGroup: vi.fn(), setPipeline: vi.fn(), diff --git a/packages/typegpu/src/core/pipeline/computePipeline.ts b/packages/typegpu/src/core/pipeline/computePipeline.ts index 9fb2499c72..48f6499742 100644 --- a/packages/typegpu/src/core/pipeline/computePipeline.ts +++ b/packages/typegpu/src/core/pipeline/computePipeline.ts @@ -1,7 +1,6 @@ import type { AnyComputeBuiltin } from '../../builtin.ts'; import type { TgpuQuerySet } from '../../core/querySet/querySet.ts'; import { type ResolvedSnippet, snip } from '../../data/snippet.ts'; -import { sizeOf } from '../../data/sizeOf.ts'; import type { AnyWgslData } from '../../data/wgslTypes.ts'; import { Void } from '../../data/wgslTypes.ts'; import { applyBindGroups } from './applyPipelineState.ts'; @@ -19,7 +18,7 @@ import { import { isGPUCommandEncoder, isGPUComputePassEncoder } from './typeGuards.ts'; import { logDataFromGPU } from '../../tgsl/consoleLog/deserializers.ts'; import type { LogResources } from '../../tgsl/consoleLog/types.ts'; -import type { ResolutionCtx, SelfResolvable } from '../../types.ts'; +import { isGPUBuffer, type ResolutionCtx, type SelfResolvable } from '../../types.ts'; import { wgslExtensions, wgslExtensionToFeatureName } from '../../wgslExtensions.ts'; import type { IORecord } from '../function/fnTypes.ts'; import type { TgpuComputeFn } from '../function/tgpuComputeFn.ts'; @@ -27,7 +26,8 @@ import { namespace } from '../resolve/namespace.ts'; import type { ExperimentalTgpuRoot } from '../root/rootTypes.ts'; import type { TgpuSlot } from '../slot/slotTypes.ts'; -import { memoryLayoutOf, type PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import type { PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import { resolveIndirectOffset } from './pipelineUtils.ts'; import { createWithPerformanceCallback, createWithTimestampWrites, @@ -72,11 +72,11 @@ export interface TgpuComputePipeline extends TgpuNamable, SelfResolvable, Timeab * The buffer must contain 3 consecutive u32 values (x, y, z workgroup counts). * To get the correct offset within complex data structures, use `d.memoryLayoutOf(...)`. * - * @param indirectBuffer - Buffer marked with 'indirect' usage containing dispatch parameters + * @param indirectBuffer - Buffer marked with 'indirect' usage containing dispatch parameters or raw GPUBuffer * @param start - PrimitiveOffsetInfo pointing to the first dispatch parameter. If not provided, starts at offset 0. To obtain safe offsets, use `d.memoryLayoutOf(...)`. */ dispatchWorkgroupsIndirect( - indirectBuffer: TgpuBuffer & IndirectFlag, + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, start?: PrimitiveOffsetInfo | number, ): void; } @@ -113,25 +113,6 @@ type Memo = { logResources: LogResources | undefined; }; -function validateIndirectBufferSize( - bufferSize: number, - offset: number, - requiredBytes: number, - operation: string, -): void { - if (offset + requiredBytes > bufferSize) { - throw new Error( - `Buffer too small for ${operation}. ` + - `Required: ${requiredBytes} bytes at offset ${offset}, ` + - `but buffer is only ${bufferSize} bytes.`, - ); - } - - if (offset % 4 !== 0) { - throw new Error(`Indirect buffer offset must be a multiple of 4. Got: ${offset}`); - } -} - const _lastAppliedCompute = new WeakMap(); class TgpuComputePipelineImpl implements TgpuComputePipeline { @@ -252,46 +233,20 @@ class TgpuComputePipelineImpl implements TgpuComputePipeline { } dispatchWorkgroupsIndirect( - indirectBuffer: TgpuBuffer & IndirectFlag, + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, start?: PrimitiveOffsetInfo | number, ): void { const DISPATCH_SIZE = 12; // 3 x u32 (x, y, z) - let offsetInfo = start ?? memoryLayoutOf(indirectBuffer.dataType); - - if (typeof offsetInfo === 'number') { - if (offsetInfo === 0) { - offsetInfo = memoryLayoutOf(indirectBuffer.dataType); - } else { - console.warn( - `dispatchWorkgroupsIndirect: Provided start offset ${offsetInfo} as a raw number. Use d.memoryLayoutOf(...) to include contiguous padding info for safer validation.`, - ); - // When only an offset is provided, assume we have at least 12 bytes contiguous. - offsetInfo = { - offset: offsetInfo, - contiguous: DISPATCH_SIZE, - }; - } - } - - const { offset, contiguous } = offsetInfo; - - validateIndirectBufferSize( - sizeOf(indirectBuffer.dataType), - offset, + const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; + const offset = resolveIndirectOffset( + indirectBuffer, + start, DISPATCH_SIZE, 'dispatchWorkgroupsIndirect', ); - if (contiguous < DISPATCH_SIZE) { - console.warn( - `dispatchWorkgroupsIndirect: Starting at offset ${offset}, only ${contiguous} contiguous bytes are available before padding. Dispatch requires ${DISPATCH_SIZE} bytes (3 x u32). Reading across padding may result in undefined behavior.`, - ); - } - - this._executeComputePass((pass) => - pass.dispatchWorkgroupsIndirect(indirectBuffer.buffer, offset), - ); + this._executeComputePass((pass) => pass.dispatchWorkgroupsIndirect(rawBuffer, offset)); } private _applyComputeState(pass: GPUComputePassEncoder): void { diff --git a/packages/typegpu/src/core/pipeline/pipelineUtils.ts b/packages/typegpu/src/core/pipeline/pipelineUtils.ts new file mode 100644 index 0000000000..80027b7386 --- /dev/null +++ b/packages/typegpu/src/core/pipeline/pipelineUtils.ts @@ -0,0 +1,60 @@ +import type { IndirectFlag, TgpuBuffer } from '../buffer/buffer.ts'; +import { memoryLayoutOf, type PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import { sizeOf } from '../../data/sizeOf.ts'; +import type { BaseData } from '../../data/wgslTypes.ts'; +import { isGPUBuffer } from '../../types.ts'; + +type IndirectOperation = 'dispatchWorkgroupsIndirect' | 'drawIndirect' | 'drawIndexedIndirect'; +const IndirectOperationToRequiredData = { + dispatchWorkgroupsIndirect: '3 x u32', + drawIndirect: '4 x u32', + drawIndexedIndirect: '3 x u32, i32, u32', +} as const satisfies Record; + +function validateIndirectBufferSize( + bufferSize: number, + offset: number, + requiredBytes: number, + operation: IndirectOperation, +): void { + if (offset + requiredBytes > bufferSize) { + throw new Error( + `Buffer too small for ${operation}. Required: ${requiredBytes} bytes at offset ${offset}, but buffer is only ${bufferSize} bytes.`, + ); + } + + if (offset % 4 !== 0) { + throw new Error(`Indirect buffer offset must be a multiple of 4. Got: ${offset}`); + } +} + +export function resolveIndirectOffset( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + start: PrimitiveOffsetInfo | number | undefined, + requiredSize: number, + operation: IndirectOperation, +): number { + if (isGPUBuffer(indirectBuffer)) { + const offset = typeof start === 'number' ? start : (start?.offset ?? 0); + validateIndirectBufferSize(indirectBuffer.size, offset, requiredSize, operation); + return offset; + } + + const offsetInfo = start + ? typeof start === 'number' + ? { offset: start, contiguous: requiredSize } + : start + : memoryLayoutOf(indirectBuffer.dataType); + + const { offset, contiguous } = offsetInfo; + + validateIndirectBufferSize(sizeOf(indirectBuffer.dataType), offset, requiredSize, operation); + + if (contiguous < requiredSize) { + console.warn( + `${operation}: Starting at offset ${offset}, only ${contiguous} contiguous bytes are available before padding. '${operation}' requires ${requiredSize} bytes (${IndirectOperationToRequiredData[operation]}). Reading across padding may result in undefined behavior.`, + ); + } + + return offset; +} diff --git a/packages/typegpu/src/core/pipeline/renderPipeline.ts b/packages/typegpu/src/core/pipeline/renderPipeline.ts index 20a7426e62..d558aeb8d8 100644 --- a/packages/typegpu/src/core/pipeline/renderPipeline.ts +++ b/packages/typegpu/src/core/pipeline/renderPipeline.ts @@ -1,5 +1,5 @@ import type { AnyBuiltin, OmitBuiltins } from '../../builtin.ts'; -import type { IndexFlag, TgpuBuffer, VertexFlag } from '../../core/buffer/buffer.ts'; +import type { IndexFlag, IndirectFlag, TgpuBuffer, VertexFlag } from '../../core/buffer/buffer.ts'; import type { TgpuQuerySet } from '../../core/querySet/querySet.ts'; import { isBuiltin } from '../../data/attributes.ts'; import { type Disarray, getCustomLocation, type UndecorateRecord } from '../../data/dataTypes.ts'; @@ -81,6 +81,11 @@ import { type TimestampWritesPriors, triggerPerformanceCallback, } from './timeable.ts'; +import { type PrimitiveOffsetInfo } from '../../data/offsetUtils.ts'; +import { resolveIndirectOffset } from './pipelineUtils.ts'; + +const DRAW_INDIRECT_SIZE = 16; // 4 x 4 +const DRAW_INDEXED_INDIRECT_SIZE = 20; // 5 x 4 interface RenderPipelineInternals { readonly core: RenderPipelineCore; @@ -185,11 +190,30 @@ export interface TgpuRenderPipeline firstInstance?: number, ): void; - drawIndirect(indirectBuffer: TgpuBuffer | GPUBuffer, indirectOffset?: GPUSize64): void; + /** + * Draws primitives using parameters read from a buffer. + * The buffer must contain 4 consecutive u32 values (vertexCount, instanceCount, firstVertex, firstInstance). + * To get the correct offset within complex data structures, use `d.memoryLayoutOf(...)`. + * + * @param indirectBuffer - Buffer marked with 'indirect' usage containing draw parameters or raw GPUBuffer + * @param indirectOffset - PrimitiveOffsetInfo pointing to the first draw parameter. If not provided, starts at offset 0. To obtain safe offsets, use `d.memoryLayoutOf(...)`. + */ + drawIndirect( + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, + ): void; + /** + * Draws indexed primitives using parameters read from a buffer. + * The buffer must contain 5 consecutive 32-bit integer values (indexCount u32, instanceCount u32, firstIndex u32, baseVertex i32, firstInstance u32). + * To get the correct offset within complex data structures, use `d.memoryLayoutOf(...)`. + * + * @param indirectBuffer - Buffer marked with 'indirect' usage containing draw parameters or raw GPUBuffer + * @param indirectOffset - PrimitiveOffsetInfo pointing to the first draw parameter. If not provided, starts at offset 0. To obtain safe offsets, use `d.memoryLayoutOf(...)`. + */ drawIndexedIndirect( - indirectBuffer: TgpuBuffer | GPUBuffer, - indirectOffset?: GPUSize64, + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, ): void; } @@ -869,26 +893,32 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { } drawIndirect( - indirectBuffer: TgpuBuffer | GPUBuffer, - indirectOffset: GPUSize64 = 0, + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, ): void { const internals = this[$internal]; const { root } = internals.core.options; - const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : root.unwrap(indirectBuffer); + const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : indirectBuffer.buffer; + const offset = resolveIndirectOffset( + indirectBuffer, + indirectOffset, + DRAW_INDIRECT_SIZE, + 'drawIndirect', + ); if (internals.priors.externalRenderEncoder) { if (_lastAppliedRender.get(internals.priors.externalRenderEncoder) !== this) { this._applyRenderState(internals.priors.externalRenderEncoder); _lastAppliedRender.set(internals.priors.externalRenderEncoder, this); } - internals.priors.externalRenderEncoder.drawIndirect(rawBuffer, indirectOffset); + internals.priors.externalRenderEncoder.drawIndirect(rawBuffer, offset); return; } if (internals.priors.externalEncoder) { const pass = this._createRenderPass(internals.priors.externalEncoder); this._applyRenderState(pass); - pass.drawIndirect(rawBuffer, indirectOffset); + pass.drawIndirect(rawBuffer, offset); pass.end(); return; } @@ -898,7 +928,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { const commandEncoder = root.device.createCommandEncoder(); const pass = this._createRenderPass(commandEncoder); this._applyRenderState(pass); - pass.drawIndirect(rawBuffer, indirectOffset); + pass.drawIndirect(rawBuffer, offset); pass.end(); root.device.queue.submit([commandEncoder.finish()]); @@ -912,12 +942,18 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { } drawIndexedIndirect( - indirectBuffer: TgpuBuffer | GPUBuffer, - indirectOffset: GPUSize64 = 0, + indirectBuffer: (TgpuBuffer & IndirectFlag) | GPUBuffer, + indirectOffset?: PrimitiveOffsetInfo | number, ): void { const internals = this[$internal]; const { root } = internals.core.options; const rawBuffer = isGPUBuffer(indirectBuffer) ? indirectBuffer : root.unwrap(indirectBuffer); + const offset = resolveIndirectOffset( + indirectBuffer, + indirectOffset, + DRAW_INDEXED_INDIRECT_SIZE, + 'drawIndexedIndirect', + ); if (internals.priors.externalRenderEncoder) { if (_lastAppliedRender.get(internals.priors.externalRenderEncoder) !== this) { @@ -925,7 +961,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { this._setIndexBuffer(internals.priors.externalRenderEncoder); _lastAppliedRender.set(internals.priors.externalRenderEncoder, this); } - internals.priors.externalRenderEncoder.drawIndexedIndirect(rawBuffer, indirectOffset); + internals.priors.externalRenderEncoder.drawIndexedIndirect(rawBuffer, offset); return; } @@ -933,7 +969,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { const pass = this._createRenderPass(internals.priors.externalEncoder); this._applyRenderState(pass); this._setIndexBuffer(pass); - pass.drawIndexedIndirect(rawBuffer, indirectOffset); + pass.drawIndexedIndirect(rawBuffer, offset); pass.end(); return; } @@ -944,7 +980,7 @@ class TgpuRenderPipelineImpl implements TgpuRenderPipeline { const pass = this._createRenderPass(commandEncoder); this._applyRenderState(pass); this._setIndexBuffer(pass); - pass.drawIndexedIndirect(rawBuffer, indirectOffset); + pass.drawIndexedIndirect(rawBuffer, offset); pass.end(); root.device.queue.submit([commandEncoder.finish()]); diff --git a/packages/typegpu/tests/computePipeline.test.ts b/packages/typegpu/tests/computePipeline.test.ts index 8e19dceff6..6fadc72872 100644 --- a/packages/typegpu/tests/computePipeline.test.ts +++ b/packages/typegpu/tests/computePipeline.test.ts @@ -595,6 +595,44 @@ describe('TgpuComputePipeline', () => { }), }); + it('accepts raw GPUBuffer with indirect flag', ({ root, device }) => { + const buffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.INDIRECT }); + + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const pipeline = root.createComputePipeline({ compute: entryFn }); + + pipeline.dispatchWorkgroupsIndirect(buffer, 4); + }); + + it('throws when offset is not multiple of 4', ({ root, device }) => { + const buffer = device.createBuffer({ size: 16, usage: GPUBufferUsage.INDIRECT }); + + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const pipeline = root.createComputePipeline({ compute: entryFn }); + + expect(() => + pipeline.dispatchWorkgroupsIndirect(buffer, 3), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Indirect buffer offset must be a multiple of 4. Got: 3]`, + ); + }); + + it('throws when raw GPUBuffer size is not enough for dispatch', ({ device, root }) => { + const buffer = device.createBuffer({ + size: 13, + usage: GPUBufferUsage.INDIRECT, + }); + + const entryFn = tgpu.computeFn({ workgroupSize: [1] })(() => {}); + const pipeline = root.createComputePipeline({ compute: entryFn }); + + expect(() => + pipeline.dispatchWorkgroupsIndirect(buffer, 4), + ).toThrowErrorMatchingInlineSnapshot( + `[Error: Buffer too small for dispatchWorkgroupsIndirect. Required: 12 bytes at offset 4, but buffer is only 13 bytes.]`, + ); + }); + it('warns when dispatch would read across padding', ({ root }) => { using warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); @@ -610,7 +648,7 @@ describe('TgpuComputePipeline', () => { ); expect(warnSpy.mock.calls[0]![0]).toMatchInlineSnapshot( - `"dispatchWorkgroupsIndirect: Starting at offset 0, only 4 contiguous bytes are available before padding. Dispatch requires 12 bytes (3 x u32). Reading across padding may result in undefined behavior."`, + `"dispatchWorkgroupsIndirect: Starting at offset 0, only 4 contiguous bytes are available before padding. 'dispatchWorkgroupsIndirect' requires 12 bytes (3 x u32). Reading across padding may result in undefined behavior."`, ); const deepBuffer = root.createBuffer(DeepStruct).$usage('indirect'); @@ -620,7 +658,7 @@ describe('TgpuComputePipeline', () => { ); expect(warnSpy.mock.calls[1]![0]).toMatchInlineSnapshot( - `"dispatchWorkgroupsIndirect: Starting at offset 44, only 8 contiguous bytes are available before padding. Dispatch requires 12 bytes (3 x u32). Reading across padding may result in undefined behavior."`, + `"dispatchWorkgroupsIndirect: Starting at offset 44, only 8 contiguous bytes are available before padding. 'dispatchWorkgroupsIndirect' requires 12 bytes (3 x u32). Reading across padding may result in undefined behavior."`, ); pipeline.dispatchWorkgroupsIndirect( @@ -629,7 +667,7 @@ describe('TgpuComputePipeline', () => { ); expect(warnSpy.mock.calls[2]![0]).toMatchInlineSnapshot( - `"dispatchWorkgroupsIndirect: Starting at offset 84, only 8 contiguous bytes are available before padding. Dispatch requires 12 bytes (3 x u32). Reading across padding may result in undefined behavior."`, + `"dispatchWorkgroupsIndirect: Starting at offset 84, only 8 contiguous bytes are available before padding. 'dispatchWorkgroupsIndirect' requires 12 bytes (3 x u32). Reading across padding may result in undefined behavior."`, ); }); diff --git a/packages/typegpu/tests/renderPipeline.test.ts b/packages/typegpu/tests/renderPipeline.test.ts index 786f30f108..316f2d0e02 100644 --- a/packages/typegpu/tests/renderPipeline.test.ts +++ b/packages/typegpu/tests/renderPipeline.test.ts @@ -2192,3 +2192,188 @@ describe('Render Bundles', () => { expect(pass.end).toHaveBeenCalled(); }); }); + +describe('drawIndirect / drawIndexedIndirect buffer and offset validation', () => { + // our favorite struct: https://shorturl.at/NQggS + const DeepStruct = d.struct({ + someData: d.arrayOf(d.f32, 13), + nested: d.struct({ + randomData: d.f32, + x: d.atomic(d.u32), + y: d.u32, + innerNested: d.arrayOf( + d.struct({ + xx: d.atomic(d.u32), + yy: d.u32, + zz: d.u32, + myVec: d.vec4u, + }), + 3, + ), + z: d.u32, + additionalData: d.arrayOf(d.u32, 32), + }), + }); + + const vertexFn = tgpu.vertexFn({ + out: { pos: d.builtin.position }, + })(''); + + const fragmentFn = tgpu.fragmentFn({ + out: { color: d.vec4f }, + })(''); + + function createPipeline(root: ExperimentalTgpuRoot) { + return root + .createRenderPipeline({ + vertex: vertexFn, + fragment: fragmentFn, + targets: { color: { format: 'rgba8unorm' } }, + }) + .withColorAttachment({ + color: { + view: {} as unknown as GPUTextureView, + loadOp: 'clear', + storeOp: 'store', + }, + }); + } + + describe('drawIndirect', () => { + it('accepts raw GPUBuffer with indirect flag', ({ root, device }) => { + const buffer = device.createBuffer({ size: 20, usage: GPUBufferUsage.INDIRECT }); + + const pipeline = createPipeline(root); + + pipeline.drawIndirect(buffer, 4); + }); + + it('throws when offset is not multiple of 4', ({ root, device }) => { + const buffer = device.createBuffer({ size: 20, usage: GPUBufferUsage.INDIRECT }); + + const pipeline = createPipeline(root); + + expect(() => pipeline.drawIndirect(buffer, 3)).toThrowErrorMatchingInlineSnapshot( + `[Error: Indirect buffer offset must be a multiple of 4. Got: 3]`, + ); + }); + + it('throws when raw GPUBuffer size is not enough for draw', ({ device, root }) => { + const buffer = device.createBuffer({ + size: 17, + usage: GPUBufferUsage.INDIRECT, + }); + + const pipeline = createPipeline(root); + + expect(() => pipeline.drawIndirect(buffer, 4)).toThrowErrorMatchingInlineSnapshot( + `[Error: Buffer too small for drawIndirect. Required: 16 bytes at offset 4, but buffer is only 17 bytes.]`, + ); + }); + + it('warns when draw would read across padding', ({ root }) => { + using warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const pipeline = createPipeline(root); + + const buffer = root.createBuffer(DeepStruct).$usage('indirect'); + pipeline.drawIndirect( + buffer, + d.memoryLayoutOf(DeepStruct, (s) => s.someData[10]), + ); + + expect(warnSpy.mock.calls[0]![0]).toMatchInlineSnapshot( + `"drawIndirect: Starting at offset 40, only 12 contiguous bytes are available before padding. 'drawIndirect' requires 16 bytes (4 x u32). Reading across padding may result in undefined behavior."`, + ); + }); + + it('does not warn when draw has sufficient contiguous data', ({ root }) => { + using warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const pipeline = createPipeline(root); + + const DrawIndirectArgs = d.struct({ + vertexCount: d.u32, + instanceCount: d.u32, + firstVertex: d.u32, + firstInstance: d.u32, + }); + const buffer = root.createBuffer(DrawIndirectArgs).$usage('indirect'); + pipeline.drawIndirect(buffer); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); + + describe('drawIndexedIndirect', () => { + function createPipelineIndexed(root: ExperimentalTgpuRoot) { + const indexBuffer = root.createBuffer(d.arrayOf(d.u32, 3)).$usage('index'); + return createPipeline(root).withIndexBuffer(indexBuffer); + } + + it('accepts raw GPUBuffer with indirect flag', ({ root, device }) => { + const buffer = device.createBuffer({ size: 24, usage: GPUBufferUsage.INDIRECT }); + + const pipeline = createPipelineIndexed(root); + + pipeline.drawIndexedIndirect(buffer, 4); + }); + + it('throws when offset is not multiple of 4', ({ root, device }) => { + const buffer = device.createBuffer({ size: 24, usage: GPUBufferUsage.INDIRECT }); + + const pipeline = createPipelineIndexed(root); + + expect(() => pipeline.drawIndexedIndirect(buffer, 3)).toThrowErrorMatchingInlineSnapshot( + `[Error: Indirect buffer offset must be a multiple of 4. Got: 3]`, + ); + }); + + it('throws when raw GPUBuffer size is not enough for drawIndexed', ({ device, root }) => { + const buffer = device.createBuffer({ + size: 21, + usage: GPUBufferUsage.INDIRECT, + }); + + const pipeline = createPipelineIndexed(root); + + expect(() => pipeline.drawIndexedIndirect(buffer, 4)).toThrowErrorMatchingInlineSnapshot( + `[Error: Buffer too small for drawIndexedIndirect. Required: 20 bytes at offset 4, but buffer is only 21 bytes.]`, + ); + }); + + it('warns when drawIndexed would read across padding', ({ root }) => { + using warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const pipeline = createPipelineIndexed(root); + + const buffer = root.createBuffer(DeepStruct).$usage('indirect'); + pipeline.drawIndexedIndirect( + buffer, + d.memoryLayoutOf(DeepStruct, (s) => s.someData[9]), + ); + + expect(warnSpy.mock.calls[0]![0]).toMatchInlineSnapshot( + `"drawIndexedIndirect: Starting at offset 36, only 16 contiguous bytes are available before padding. 'drawIndexedIndirect' requires 20 bytes (3 x u32, i32, u32). Reading across padding may result in undefined behavior."`, + ); + }); + + it('does not warn when drawIndexed has sufficient contiguous data', ({ root }) => { + using warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const pipeline = createPipelineIndexed(root); + + const DrawIndexedIndirectArgs = d.struct({ + indexCount: d.u32, + instanceCount: d.u32, + firstIndex: d.u32, + baseVertex: d.i32, + firstInstance: d.u32, + }); + const buffer = root.createBuffer(DrawIndexedIndirectArgs).$usage('indirect'); + pipeline.drawIndexedIndirect(buffer); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); +});