diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds')
16 files changed, 4307 insertions, 0 deletions
diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/clearBuffer.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/clearBuffer.spec.ts new file mode 100644 index 0000000000..7e90db8545 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/clearBuffer.spec.ts @@ -0,0 +1,246 @@ +export const description = ` +API validation tests for clearBuffer. +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { kBufferUsages } from '../../../../capability_info.js'; +import { kResourceStates } from '../../../../gpu_test.js'; +import { kMaxSafeMultipleOf8 } from '../../../../util/math.js'; +import { ValidationTest } from '../../validation_test.js'; + +class F extends ValidationTest { + TestClearBuffer(options: { + buffer: GPUBuffer; + offset: number | undefined; + size: number | undefined; + isSuccess: boolean; + }): void { + const { buffer, offset, size, isSuccess } = options; + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.clearBuffer(buffer, offset, size); + + this.expectValidationError(() => { + commandEncoder.finish(); + }, !isSuccess); + } +} + +export const g = makeTestGroup(F); + +g.test('buffer_state') + .desc(`Test that clearing an invalid or destroyed buffer fails.`) + .params(u => u.combine('bufferState', kResourceStates)) + .fn(async t => { + const { bufferState } = t.params; + + const buffer = t.createBufferWithState(bufferState, { + size: 8, + usage: GPUBufferUsage.COPY_DST, + }); + + const commandEncoder = t.device.createCommandEncoder(); + commandEncoder.clearBuffer(buffer, 0, 8); + + if (bufferState === 'invalid') { + t.expectValidationError(() => { + commandEncoder.finish(); + }); + } else { + const cmd = commandEncoder.finish(); + t.expectValidationError(() => { + t.device.queue.submit([cmd]); + }, bufferState === 'destroyed'); + } + }); + +g.test('buffer,device_mismatch') + .desc(`Tests clearBuffer cannot be called with buffer created from another device.`) + .paramsSubcasesOnly(u => u.combine('mismatched', [true, false])) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { mismatched } = t.params; + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + const size = 8; + + const buffer = sourceDevice.createBuffer({ + size, + usage: GPUBufferUsage.COPY_DST, + }); + t.trackForCleanup(buffer); + + t.TestClearBuffer({ + buffer, + offset: 0, + size, + isSuccess: !mismatched, + }); + }); + +g.test('default_args') + .desc(`Test that calling clearBuffer with a default offset and size is valid.`) + .paramsSubcasesOnly([ + { offset: undefined, size: undefined }, + { offset: 4, size: undefined }, + { offset: undefined, size: 8 }, + ] as const) + .fn(async t => { + const { offset, size } = t.params; + + const buffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestClearBuffer({ + buffer, + offset, + size, + isSuccess: true, + }); + }); + +g.test('buffer_usage') + .desc(`Test that only buffers with COPY_DST usage are valid to use with copyBuffers.`) + .paramsSubcasesOnly(u => + u // + .combine('usage', kBufferUsages) + ) + .fn(async t => { + const { usage } = t.params; + + const buffer = t.device.createBuffer({ + size: 16, + usage, + }); + + t.TestClearBuffer({ + buffer, + offset: 0, + size: 16, + isSuccess: usage === GPUBufferUsage.COPY_DST, + }); + }); + +g.test('size_alignment') + .desc( + ` + Test that the clear size must be 4 byte aligned. + - Test size is not a multiple of 4. + - Test size is 0. + - Test size overflows the buffer size. + - Test size is omitted. + ` + ) + .paramsSubcasesOnly([ + { size: 0, _isSuccess: true }, + { size: 2, _isSuccess: false }, + { size: 4, _isSuccess: true }, + { size: 5, _isSuccess: false }, + { size: 8, _isSuccess: true }, + { size: 20, _isSuccess: false }, + { size: undefined, _isSuccess: true }, + ] as const) + .fn(async t => { + const { size, _isSuccess: isSuccess } = t.params; + + const buffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestClearBuffer({ + buffer, + offset: 0, + size, + isSuccess, + }); + }); + +g.test('offset_alignment') + .desc( + ` + Test that the clear offsets must be 4 byte aligned. + - Test offset is not a multiple of 4. + - Test offset is larger than the buffer size. + - Test offset is omitted. + ` + ) + .paramsSubcasesOnly([ + { offset: 0, _isSuccess: true }, + { offset: 2, _isSuccess: false }, + { offset: 4, _isSuccess: true }, + { offset: 5, _isSuccess: false }, + { offset: 8, _isSuccess: true }, + { offset: 20, _isSuccess: false }, + { offset: undefined, _isSuccess: true }, + ] as const) + .fn(async t => { + const { offset, _isSuccess: isSuccess } = t.params; + + const buffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestClearBuffer({ + buffer, + offset, + size: 8, + isSuccess, + }); + }); + +g.test('overflow') + .desc(`Test that clears which may cause arthimetic overflows are invalid.`) + .paramsSubcasesOnly([ + { offset: 0, size: kMaxSafeMultipleOf8 }, + { offset: 16, size: kMaxSafeMultipleOf8 }, + { offset: kMaxSafeMultipleOf8, size: 16 }, + { offset: kMaxSafeMultipleOf8, size: kMaxSafeMultipleOf8 }, + ] as const) + .fn(async t => { + const { offset, size } = t.params; + + const buffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestClearBuffer({ + buffer, + offset, + size, + isSuccess: false, + }); + }); + +g.test('out_of_bounds') + .desc(`Test that clears which exceed the buffer bounds are invalid.`) + .paramsSubcasesOnly([ + { offset: 0, size: 32, _isSuccess: true }, + { offset: 0, size: 36 }, + { offset: 32, size: 0, _isSuccess: true }, + { offset: 32, size: 4 }, + { offset: 36, size: 4 }, + { offset: 36, size: 0 }, + { offset: 20, size: 16 }, + { offset: 20, size: 12, _isSuccess: true }, + ] as const) + .fn(async t => { + const { offset, size, _isSuccess = false } = t.params; + + const buffer = t.device.createBuffer({ + size: 32, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestClearBuffer({ + buffer, + offset, + size, + isSuccess: _isSuccess, + }); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/compute_pass.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/compute_pass.spec.ts new file mode 100644 index 0000000000..0a90793224 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/compute_pass.spec.ts @@ -0,0 +1,250 @@ +export const description = ` +API validation test for compute pass + +Does **not** test usage scopes (resource_usages/) or programmable pass stuff (programmable_pass). +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { kBufferUsages, kLimitInfo } from '../../../../capability_info.js'; +import { GPUConst } from '../../../../constants.js'; +import { kResourceStates, ResourceState } from '../../../../gpu_test.js'; +import { ValidationTest } from '../../validation_test.js'; + +class F extends ValidationTest { + createComputePipeline(state: 'valid' | 'invalid'): GPUComputePipeline { + if (state === 'valid') { + return this.createNoOpComputePipeline(); + } + + return this.createErrorComputePipeline(); + } + + createIndirectBuffer(state: ResourceState, data: Uint32Array): GPUBuffer { + const descriptor: GPUBufferDescriptor = { + size: data.byteLength, + usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST, + }; + + if (state === 'invalid') { + descriptor.usage = 0xffff; // Invalid GPUBufferUsage + } + + this.device.pushErrorScope('validation'); + const buffer = this.device.createBuffer(descriptor); + void this.device.popErrorScope(); + + if (state === 'valid') { + this.queue.writeBuffer(buffer, 0, data); + } + + if (state === 'destroyed') { + buffer.destroy(); + } + + return buffer; + } +} + +export const g = makeTestGroup(F); + +g.test('set_pipeline') + .desc( + ` +setPipeline should generate an error iff using an 'invalid' pipeline. +` + ) + .params(u => u.beginSubcases().combine('state', ['valid', 'invalid'] as const)) + .fn(t => { + const { state } = t.params; + const pipeline = t.createComputePipeline(state); + + const { encoder, validateFinishAndSubmitGivenState } = t.createEncoder('compute pass'); + encoder.setPipeline(pipeline); + validateFinishAndSubmitGivenState(state); + }); + +g.test('pipeline,device_mismatch') + .desc('Tests setPipeline cannot be called with a compute pipeline created from another device') + .paramsSubcasesOnly(u => u.combine('mismatched', [true, false])) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { mismatched } = t.params; + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + + const pipeline = sourceDevice.createComputePipeline({ + layout: 'auto', + compute: { + module: sourceDevice.createShaderModule({ + code: '@compute @workgroup_size(1) fn main() {}', + }), + entryPoint: 'main', + }, + }); + + const { encoder, validateFinish } = t.createEncoder('compute pass'); + encoder.setPipeline(pipeline); + validateFinish(!mismatched); + }); + +const kMaxDispatch = kLimitInfo.maxComputeWorkgroupsPerDimension.default; +g.test('dispatch_sizes') + .desc( + `Test 'direct' and 'indirect' dispatch with various sizes. + + Only direct dispatches can produce validation errors. + Workgroup sizes: + - valid: { zero, one, just under limit } + - invalid: { just over limit, way over limit } + + TODO: Verify that the invalid cases don't execute any invocations at all. +` + ) + .params(u => + u + .combine('dispatchType', ['direct', 'indirect'] as const) + .combine('largeDimValue', [0, 1, kMaxDispatch, kMaxDispatch + 1, 0x7fff_ffff, 0xffff_ffff]) + .beginSubcases() + .combine('largeDimIndex', [0, 1, 2] as const) + .combine('smallDimValue', [0, 1]) + ) + .fn(t => { + const { dispatchType, largeDimIndex, smallDimValue, largeDimValue } = t.params; + + const pipeline = t.createNoOpComputePipeline(); + + const workSizes = [smallDimValue, smallDimValue, smallDimValue]; + workSizes[largeDimIndex] = largeDimValue; + + const { encoder, validateFinishAndSubmit } = t.createEncoder('compute pass'); + encoder.setPipeline(pipeline); + if (dispatchType === 'direct') { + const [x, y, z] = workSizes; + encoder.dispatchWorkgroups(x, y, z); + } else if (dispatchType === 'indirect') { + encoder.dispatchWorkgroupsIndirect( + t.createIndirectBuffer('valid', new Uint32Array(workSizes)), + 0 + ); + } + + const shouldError = + dispatchType === 'direct' && + (workSizes[0] > kMaxDispatch || workSizes[1] > kMaxDispatch || workSizes[2] > kMaxDispatch); + + validateFinishAndSubmit(!shouldError, true); + }); + +const kBufferData = new Uint32Array(6).fill(1); +g.test('indirect_dispatch_buffer_state') + .desc( + ` +Test dispatchWorkgroupsIndirect validation by submitting various dispatches with a no-op pipeline +and an indirectBuffer with 6 elements. +- indirectBuffer: {'valid', 'invalid', 'destroyed'} +- indirectOffset: + - valid, within the buffer: {beginning, middle, end} of the buffer + - invalid, non-multiple of 4 + - invalid, the last element is outside the buffer +` + ) + .paramsSubcasesOnly(u => + u // + .combine('state', kResourceStates) + .combine('offset', [ + // valid (for 'valid' buffers) + 0, + Uint32Array.BYTES_PER_ELEMENT, + kBufferData.byteLength - 3 * Uint32Array.BYTES_PER_ELEMENT, + // invalid, non-multiple of 4 offset + 1, + // invalid, last element outside buffer + kBufferData.byteLength - 2 * Uint32Array.BYTES_PER_ELEMENT, + ]) + ) + .fn(t => { + const { state, offset } = t.params; + const pipeline = t.createNoOpComputePipeline(); + const buffer = t.createIndirectBuffer(state, kBufferData); + + const { encoder, validateFinishAndSubmit } = t.createEncoder('compute pass'); + encoder.setPipeline(pipeline); + encoder.dispatchWorkgroupsIndirect(buffer, offset); + + const finishShouldError = + state === 'invalid' || + offset % 4 !== 0 || + offset + 3 * Uint32Array.BYTES_PER_ELEMENT > kBufferData.byteLength; + validateFinishAndSubmit(!finishShouldError, state !== 'destroyed'); + }); + +g.test('indirect_dispatch_buffer,device_mismatch') + .desc( + `Tests dispatchWorkgroupsIndirect cannot be called with an indirect buffer created from another device` + ) + .paramsSubcasesOnly(u => u.combine('mismatched', [true, false])) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { mismatched } = t.params; + + const pipeline = t.createNoOpComputePipeline(); + + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + + const buffer = sourceDevice.createBuffer({ + size: 16, + usage: GPUBufferUsage.INDIRECT, + }); + t.trackForCleanup(buffer); + + const { encoder, validateFinish } = t.createEncoder('compute pass'); + encoder.setPipeline(pipeline); + encoder.dispatchWorkgroupsIndirect(buffer, 0); + validateFinish(!mismatched); + }); + +g.test('indirect_dispatch_buffer,usage') + .desc( + ` + Tests dispatchWorkgroupsIndirect generates a validation error if the buffer usage does not + contain INDIRECT usage. + ` + ) + .paramsSubcasesOnly(u => + u + // If bufferUsage0 and bufferUsage1 are the same, the usage being test is a single usage. + // Otherwise, it's a combined usage. + .combine('bufferUsage0', kBufferUsages) + .combine('bufferUsage1', kBufferUsages) + .unless( + ({ bufferUsage0, bufferUsage1 }) => + ((bufferUsage0 | bufferUsage1) & + (GPUConst.BufferUsage.MAP_READ | GPUConst.BufferUsage.MAP_WRITE)) !== + 0 + ) + ) + .fn(async t => { + const { bufferUsage0, bufferUsage1 } = t.params; + + const bufferUsage = bufferUsage0 | bufferUsage1; + + const layout = t.device.createPipelineLayout({ bindGroupLayouts: [] }); + const pipeline = t.createNoOpComputePipeline(layout); + + const buffer = t.device.createBuffer({ + size: 16, + usage: bufferUsage, + }); + t.trackForCleanup(buffer); + + const success = (GPUBufferUsage.INDIRECT & bufferUsage) !== 0; + + const { encoder, validateFinish } = t.createEncoder('compute pass'); + encoder.setPipeline(pipeline); + + encoder.dispatchWorkgroupsIndirect(buffer, 0); + validateFinish(success); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/copyBufferToBuffer.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/copyBufferToBuffer.spec.ts new file mode 100644 index 0000000000..918bebf7d7 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/copyBufferToBuffer.spec.ts @@ -0,0 +1,326 @@ +export const description = ` +copyBufferToBuffer tests. + +Test Plan: +* Buffer is valid/invalid + - the source buffer is invalid + - the destination buffer is invalid +* Buffer usages + - the source buffer is created without GPUBufferUsage::COPY_SRC + - the destination buffer is created without GPUBufferUsage::COPY_DEST +* CopySize + - copySize is not a multiple of 4 + - copySize is 0 +* copy offsets + - sourceOffset is not a multiple of 4 + - destinationOffset is not a multiple of 4 +* Arithmetic overflow + - (sourceOffset + copySize) is overflow + - (destinationOffset + copySize) is overflow +* Out of bounds + - (sourceOffset + copySize) > size of source buffer + - (destinationOffset + copySize) > size of destination buffer +* Source buffer and destination buffer are the same buffer +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { kBufferUsages } from '../../../../capability_info.js'; +import { kResourceStates } from '../../../../gpu_test.js'; +import { kMaxSafeMultipleOf8 } from '../../../../util/math.js'; +import { ValidationTest } from '../../validation_test.js'; + +class F extends ValidationTest { + TestCopyBufferToBuffer(options: { + srcBuffer: GPUBuffer; + srcOffset: number; + dstBuffer: GPUBuffer; + dstOffset: number; + copySize: number; + expectation: 'Success' | 'FinishError' | 'SubmitError'; + }): void { + const { srcBuffer, srcOffset, dstBuffer, dstOffset, copySize, expectation } = options; + + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyBufferToBuffer(srcBuffer, srcOffset, dstBuffer, dstOffset, copySize); + + if (expectation === 'FinishError') { + this.expectValidationError(() => { + commandEncoder.finish(); + }); + } else { + const cmd = commandEncoder.finish(); + this.expectValidationError(() => { + this.device.queue.submit([cmd]); + }, expectation === 'SubmitError'); + } + } +} + +export const g = makeTestGroup(F); + +g.test('buffer_state') + .params(u => + u // + .combine('srcBufferState', kResourceStates) + .combine('dstBufferState', kResourceStates) + ) + .fn(async t => { + const { srcBufferState, dstBufferState } = t.params; + const srcBuffer = t.createBufferWithState(srcBufferState, { + size: 16, + usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + const dstBuffer = t.createBufferWithState(dstBufferState, { + size: 16, + usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + + const shouldFinishError = srcBufferState === 'invalid' || dstBufferState === 'invalid'; + const shouldSubmitSuccess = srcBufferState === 'valid' && dstBufferState === 'valid'; + const expectation = shouldSubmitSuccess + ? 'Success' + : shouldFinishError + ? 'FinishError' + : 'SubmitError'; + + t.TestCopyBufferToBuffer({ + srcBuffer, + srcOffset: 0, + dstBuffer, + dstOffset: 0, + copySize: 8, + expectation, + }); + }); + +g.test('buffer,device_mismatch') + .desc( + 'Tests copyBufferToBuffer cannot be called with src buffer or dst buffer created from another device' + ) + .paramsSubcasesOnly([ + { srcMismatched: false, dstMismatched: false }, // control case + { srcMismatched: true, dstMismatched: false }, + { srcMismatched: false, dstMismatched: true }, + ] as const) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { srcMismatched, dstMismatched } = t.params; + + const srcBufferDevice = srcMismatched ? t.mismatchedDevice : t.device; + const srcBuffer = srcBufferDevice.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_SRC, + }); + t.trackForCleanup(srcBuffer); + + const dstBufferDevice = dstMismatched ? t.mismatchedDevice : t.device; + const dstBuffer = dstBufferDevice.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + t.trackForCleanup(dstBuffer); + + t.TestCopyBufferToBuffer({ + srcBuffer, + srcOffset: 0, + dstBuffer, + dstOffset: 0, + copySize: 8, + expectation: srcMismatched || dstMismatched ? 'FinishError' : 'Success', + }); + }); + +g.test('buffer_usage') + .paramsSubcasesOnly(u => + u // + .combine('srcUsage', kBufferUsages) + .combine('dstUsage', kBufferUsages) + ) + .fn(async t => { + const { srcUsage, dstUsage } = t.params; + + const srcBuffer = t.device.createBuffer({ + size: 16, + usage: srcUsage, + }); + const dstBuffer = t.device.createBuffer({ + size: 16, + usage: dstUsage, + }); + + const isSuccess = srcUsage === GPUBufferUsage.COPY_SRC && dstUsage === GPUBufferUsage.COPY_DST; + const expectation = isSuccess ? 'Success' : 'FinishError'; + + t.TestCopyBufferToBuffer({ + srcBuffer, + srcOffset: 0, + dstBuffer, + dstOffset: 0, + copySize: 8, + expectation, + }); + }); + +g.test('copy_size_alignment') + .paramsSubcasesOnly([ + { copySize: 0, _isSuccess: true }, + { copySize: 2, _isSuccess: false }, + { copySize: 4, _isSuccess: true }, + { copySize: 5, _isSuccess: false }, + { copySize: 8, _isSuccess: true }, + ] as const) + .fn(async t => { + const { copySize, _isSuccess: isSuccess } = t.params; + + const srcBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_SRC, + }); + const dstBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestCopyBufferToBuffer({ + srcBuffer, + srcOffset: 0, + dstBuffer, + dstOffset: 0, + copySize, + expectation: isSuccess ? 'Success' : 'FinishError', + }); + }); + +g.test('copy_offset_alignment') + .paramsSubcasesOnly([ + { srcOffset: 0, dstOffset: 0, _isSuccess: true }, + { srcOffset: 2, dstOffset: 0, _isSuccess: false }, + { srcOffset: 4, dstOffset: 0, _isSuccess: true }, + { srcOffset: 5, dstOffset: 0, _isSuccess: false }, + { srcOffset: 8, dstOffset: 0, _isSuccess: true }, + { srcOffset: 0, dstOffset: 2, _isSuccess: false }, + { srcOffset: 0, dstOffset: 4, _isSuccess: true }, + { srcOffset: 0, dstOffset: 5, _isSuccess: false }, + { srcOffset: 0, dstOffset: 8, _isSuccess: true }, + { srcOffset: 4, dstOffset: 4, _isSuccess: true }, + ] as const) + .fn(async t => { + const { srcOffset, dstOffset, _isSuccess: isSuccess } = t.params; + + const srcBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_SRC, + }); + const dstBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestCopyBufferToBuffer({ + srcBuffer, + srcOffset, + dstBuffer, + dstOffset, + copySize: 8, + expectation: isSuccess ? 'Success' : 'FinishError', + }); + }); + +g.test('copy_overflow') + .paramsSubcasesOnly([ + { srcOffset: 0, dstOffset: 0, copySize: kMaxSafeMultipleOf8 }, + { srcOffset: 16, dstOffset: 0, copySize: kMaxSafeMultipleOf8 }, + { srcOffset: 0, dstOffset: 16, copySize: kMaxSafeMultipleOf8 }, + { srcOffset: kMaxSafeMultipleOf8, dstOffset: 0, copySize: 16 }, + { srcOffset: 0, dstOffset: kMaxSafeMultipleOf8, copySize: 16 }, + { srcOffset: kMaxSafeMultipleOf8, dstOffset: 0, copySize: kMaxSafeMultipleOf8 }, + { srcOffset: 0, dstOffset: kMaxSafeMultipleOf8, copySize: kMaxSafeMultipleOf8 }, + { + srcOffset: kMaxSafeMultipleOf8, + dstOffset: kMaxSafeMultipleOf8, + copySize: kMaxSafeMultipleOf8, + }, + ] as const) + .fn(async t => { + const { srcOffset, dstOffset, copySize } = t.params; + + const srcBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_SRC, + }); + const dstBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestCopyBufferToBuffer({ + srcBuffer, + srcOffset, + dstBuffer, + dstOffset, + copySize, + expectation: 'FinishError', + }); + }); + +g.test('copy_out_of_bounds') + .paramsSubcasesOnly([ + { srcOffset: 0, dstOffset: 0, copySize: 32, _isSuccess: true }, + { srcOffset: 0, dstOffset: 0, copySize: 36 }, + { srcOffset: 36, dstOffset: 0, copySize: 4 }, + { srcOffset: 0, dstOffset: 36, copySize: 4 }, + { srcOffset: 36, dstOffset: 0, copySize: 0 }, + { srcOffset: 0, dstOffset: 36, copySize: 0 }, + { srcOffset: 20, dstOffset: 0, copySize: 16 }, + { srcOffset: 20, dstOffset: 0, copySize: 12, _isSuccess: true }, + { srcOffset: 0, dstOffset: 20, copySize: 16 }, + { srcOffset: 0, dstOffset: 20, copySize: 12, _isSuccess: true }, + ] as const) + .fn(async t => { + const { srcOffset, dstOffset, copySize, _isSuccess = false } = t.params; + + const srcBuffer = t.device.createBuffer({ + size: 32, + usage: GPUBufferUsage.COPY_SRC, + }); + const dstBuffer = t.device.createBuffer({ + size: 32, + usage: GPUBufferUsage.COPY_DST, + }); + + t.TestCopyBufferToBuffer({ + srcBuffer, + srcOffset, + dstBuffer, + dstOffset, + copySize, + expectation: _isSuccess ? 'Success' : 'FinishError', + }); + }); + +g.test('copy_within_same_buffer') + .paramsSubcasesOnly([ + { srcOffset: 0, dstOffset: 8, copySize: 4 }, + { srcOffset: 8, dstOffset: 0, copySize: 4 }, + { srcOffset: 0, dstOffset: 4, copySize: 8 }, + { srcOffset: 4, dstOffset: 0, copySize: 8 }, + ] as const) + .fn(async t => { + const { srcOffset, dstOffset, copySize } = t.params; + + const buffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, + }); + + t.TestCopyBufferToBuffer({ + srcBuffer: buffer, + srcOffset, + dstBuffer: buffer, + dstOffset, + copySize, + expectation: 'FinishError', + }); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/copyTextureToTexture.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/copyTextureToTexture.spec.ts new file mode 100644 index 0000000000..9d01055e6d --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/copyTextureToTexture.spec.ts @@ -0,0 +1,876 @@ +export const description = ` +copyTextureToTexture tests. +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { + kTextureFormatInfo, + kTextureFormats, + kCompressedTextureFormats, + kDepthStencilFormats, + kTextureUsages, + textureDimensionAndFormatCompatible, + kTextureDimensions, + kFeaturesForFormats, + filterFormatsByFeature, +} from '../../../../capability_info.js'; +import { kResourceStates } from '../../../../gpu_test.js'; +import { align, lcm } from '../../../../util/math.js'; +import { ValidationTest } from '../../validation_test.js'; + +class F extends ValidationTest { + TestCopyTextureToTexture( + source: GPUImageCopyTexture, + destination: GPUImageCopyTexture, + copySize: GPUExtent3D, + expectation: 'Success' | 'FinishError' | 'SubmitError' + ): void { + const commandEncoder = this.device.createCommandEncoder(); + commandEncoder.copyTextureToTexture(source, destination, copySize); + + if (expectation === 'FinishError') { + this.expectValidationError(() => { + commandEncoder.finish(); + }); + } else { + const cmd = commandEncoder.finish(); + this.expectValidationError(() => { + this.device.queue.submit([cmd]); + }, expectation === 'SubmitError'); + } + } + + GetPhysicalSubresourceSize( + dimension: GPUTextureDimension, + textureSize: Required<GPUExtent3DDict>, + format: GPUTextureFormat, + mipLevel: number + ): Required<GPUExtent3DDict> { + const virtualWidthAtLevel = Math.max(textureSize.width >> mipLevel, 1); + const virtualHeightAtLevel = Math.max(textureSize.height >> mipLevel, 1); + const physicalWidthAtLevel = align(virtualWidthAtLevel, kTextureFormatInfo[format].blockWidth); + const physicalHeightAtLevel = align( + virtualHeightAtLevel, + kTextureFormatInfo[format].blockHeight + ); + + switch (dimension) { + case '1d': + return { width: physicalWidthAtLevel, height: 1, depthOrArrayLayers: 1 }; + case '2d': + return { + width: physicalWidthAtLevel, + height: physicalHeightAtLevel, + depthOrArrayLayers: textureSize.depthOrArrayLayers, + }; + case '3d': + return { + width: physicalWidthAtLevel, + height: physicalHeightAtLevel, + depthOrArrayLayers: Math.max(textureSize.depthOrArrayLayers >> mipLevel, 1), + }; + } + } +} + +export const g = makeTestGroup(F); + +g.test('copy_with_invalid_or_destroyed_texture') + .desc('Test copyTextureToTexture is an error when one of the textures is invalid or destroyed.') + .paramsSubcasesOnly(u => + u // + .combine('srcState', kResourceStates) + .combine('dstState', kResourceStates) + ) + .fn(async t => { + const { srcState, dstState } = t.params; + + const textureDesc: GPUTextureDescriptor = { + size: { width: 4, height: 4, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST, + }; + + const srcTexture = t.createTextureWithState(srcState, textureDesc); + const dstTexture = t.createTextureWithState(dstState, textureDesc); + + const isSubmitSuccess = srcState === 'valid' && dstState === 'valid'; + const isFinishSuccess = srcState !== 'invalid' && dstState !== 'invalid'; + const expectation = isFinishSuccess + ? isSubmitSuccess + ? 'Success' + : 'SubmitError' + : 'FinishError'; + + t.TestCopyTextureToTexture( + { texture: srcTexture }, + { texture: dstTexture }, + { width: 1, height: 1, depthOrArrayLayers: 1 }, + expectation + ); + }); + +g.test('texture,device_mismatch') + .desc( + 'Tests copyTextureToTexture cannot be called with src texture or dst texture created from another device.' + ) + .paramsSubcasesOnly([ + { srcMismatched: false, dstMismatched: false }, // control case + { srcMismatched: true, dstMismatched: false }, + { srcMismatched: false, dstMismatched: true }, + ] as const) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { srcMismatched, dstMismatched } = t.params; + + const size = { width: 4, height: 4, depthOrArrayLayers: 1 }; + const format = 'rgba8unorm'; + + const srcTextureDevice = srcMismatched ? t.mismatchedDevice : t.device; + const srcTexture = srcTextureDevice.createTexture({ + size, + format, + usage: GPUTextureUsage.COPY_SRC, + }); + t.trackForCleanup(srcTexture); + + const dstTextureDevice = dstMismatched ? t.mismatchedDevice : t.device; + const dstTexture = dstTextureDevice.createTexture({ + size, + format, + usage: GPUTextureUsage.COPY_DST, + }); + t.trackForCleanup(dstTexture); + + t.TestCopyTextureToTexture( + { texture: srcTexture }, + { texture: dstTexture }, + { width: 1, height: 1, depthOrArrayLayers: 1 }, + srcMismatched || dstMismatched ? 'FinishError' : 'Success' + ); + }); + +g.test('mipmap_level') + .desc( + ` +Test copyTextureToTexture must specify mipLevels that are in range. +- for various dimensions +- for various mip level count in the texture +- for various copy target mip level (in range and not in range) +` + ) + .params(u => + u // + .combine('dimension', kTextureDimensions) + .beginSubcases() + .combineWithParams([ + { srcLevelCount: 1, dstLevelCount: 1, srcCopyLevel: 0, dstCopyLevel: 0 }, + { srcLevelCount: 1, dstLevelCount: 1, srcCopyLevel: 1, dstCopyLevel: 0 }, + { srcLevelCount: 1, dstLevelCount: 1, srcCopyLevel: 0, dstCopyLevel: 1 }, + { srcLevelCount: 3, dstLevelCount: 3, srcCopyLevel: 0, dstCopyLevel: 0 }, + { srcLevelCount: 3, dstLevelCount: 3, srcCopyLevel: 2, dstCopyLevel: 0 }, + { srcLevelCount: 3, dstLevelCount: 3, srcCopyLevel: 3, dstCopyLevel: 0 }, + { srcLevelCount: 3, dstLevelCount: 3, srcCopyLevel: 0, dstCopyLevel: 2 }, + { srcLevelCount: 3, dstLevelCount: 3, srcCopyLevel: 0, dstCopyLevel: 3 }, + ] as const) + .unless(p => p.dimension === '1d' && (p.srcLevelCount !== 1 || p.dstLevelCount !== 1)) + ) + + .fn(async t => { + const { srcLevelCount, dstLevelCount, srcCopyLevel, dstCopyLevel, dimension } = t.params; + + const srcTexture = t.device.createTexture({ + size: { width: 32, height: 1, depthOrArrayLayers: 1 }, + dimension, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_SRC, + mipLevelCount: srcLevelCount, + }); + const dstTexture = t.device.createTexture({ + size: { width: 32, height: 1, depthOrArrayLayers: 1 }, + dimension, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_DST, + mipLevelCount: dstLevelCount, + }); + + const isSuccess = srcCopyLevel < srcLevelCount && dstCopyLevel < dstLevelCount; + t.TestCopyTextureToTexture( + { texture: srcTexture, mipLevel: srcCopyLevel }, + { texture: dstTexture, mipLevel: dstCopyLevel }, + { width: 1, height: 1, depthOrArrayLayers: 1 }, + isSuccess ? 'Success' : 'FinishError' + ); + }); + +g.test('texture_usage') + .desc( + ` +Test that copyTextureToTexture source/destination need COPY_SRC/COPY_DST usages. +- for all possible source texture usages +- for all possible destination texture usages +` + ) + .paramsSubcasesOnly(u => + u // + .combine('srcUsage', kTextureUsages) + .combine('dstUsage', kTextureUsages) + ) + .fn(async t => { + const { srcUsage, dstUsage } = t.params; + + const srcTexture = t.device.createTexture({ + size: { width: 4, height: 4, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: srcUsage, + }); + const dstTexture = t.device.createTexture({ + size: { width: 4, height: 4, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: dstUsage, + }); + + const isSuccess = + srcUsage === GPUTextureUsage.COPY_SRC && dstUsage === GPUTextureUsage.COPY_DST; + + t.TestCopyTextureToTexture( + { texture: srcTexture }, + { texture: dstTexture }, + { width: 1, height: 1, depthOrArrayLayers: 1 }, + isSuccess ? 'Success' : 'FinishError' + ); + }); + +g.test('sample_count') + .desc( + ` +Test that textures in copyTextureToTexture must have the same sample count. +- for various source texture sample count +- for various destination texture sample count +` + ) + .paramsSubcasesOnly(u => + u // + .combine('srcSampleCount', [1, 4]) + .combine('dstSampleCount', [1, 4]) + ) + .fn(async t => { + const { srcSampleCount, dstSampleCount } = t.params; + + const srcTexture = t.device.createTexture({ + size: { width: 4, height: 4, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: srcSampleCount, + }); + const dstTexture = t.device.createTexture({ + size: { width: 4, height: 4, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: dstSampleCount, + }); + + const isSuccess = srcSampleCount === dstSampleCount; + t.TestCopyTextureToTexture( + { texture: srcTexture }, + { texture: dstTexture }, + { width: 4, height: 4, depthOrArrayLayers: 1 }, + isSuccess ? 'Success' : 'FinishError' + ); + }); + +g.test('multisampled_copy_restrictions') + .desc( + ` +Test that copyTextureToTexture of multisampled texture must copy a whole subresource to a whole subresource. +- for various origin for the source and destination of the copies. + +Note: this is only tested for 2D textures as it is the only dimension compatible with multisampling. +TODO: Check the source and destination constraints separately. +` + ) + .paramsSubcasesOnly(u => + u // + .combine('srcCopyOrigin', [ + { x: 0, y: 0, z: 0 }, + { x: 1, y: 0, z: 0 }, + { x: 0, y: 1, z: 0 }, + { x: 1, y: 1, z: 0 }, + ]) + .combine('dstCopyOrigin', [ + { x: 0, y: 0, z: 0 }, + { x: 1, y: 0, z: 0 }, + { x: 0, y: 1, z: 0 }, + { x: 1, y: 1, z: 0 }, + ]) + .expand('copyWidth', p => [32 - Math.max(p.srcCopyOrigin.x, p.dstCopyOrigin.x), 16]) + .expand('copyHeight', p => [16 - Math.max(p.srcCopyOrigin.y, p.dstCopyOrigin.y), 8]) + ) + .fn(async t => { + const { srcCopyOrigin, dstCopyOrigin, copyWidth, copyHeight } = t.params; + + const kWidth = 32; + const kHeight = 16; + + // Currently we don't support multisampled 2D array textures and the mipmap level count of the + // multisampled textures must be 1. + const srcTexture = t.device.createTexture({ + size: { width: kWidth, height: kHeight, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: 4, + }); + const dstTexture = t.device.createTexture({ + size: { width: kWidth, height: kHeight, depthOrArrayLayers: 1 }, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + sampleCount: 4, + }); + + const isSuccess = copyWidth === kWidth && copyHeight === kHeight; + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: srcCopyOrigin }, + { texture: dstTexture, origin: dstCopyOrigin }, + { width: copyWidth, height: copyHeight, depthOrArrayLayers: 1 }, + isSuccess ? 'Success' : 'FinishError' + ); + }); + +g.test('texture_format_compatibility') + .desc( + ` +Test the formats of textures in copyTextureToTexture must be copy-compatible. +- for all source texture formats +- for all destination texture formats +` + ) + .params(u => + u + .combine('srcFormatFeature', kFeaturesForFormats) + .combine('dstFormatFeature', kFeaturesForFormats) + .beginSubcases() + .expand('srcFormat', ({ srcFormatFeature }) => + filterFormatsByFeature(srcFormatFeature, kTextureFormats) + ) + .expand('dstFormat', ({ dstFormatFeature }) => + filterFormatsByFeature(dstFormatFeature, kTextureFormats) + ) + ) + .beforeAllSubcases(t => { + const { srcFormatFeature, dstFormatFeature } = t.params; + t.selectDeviceOrSkipTestCase([srcFormatFeature, dstFormatFeature]); + }) + .fn(async t => { + const { srcFormat, dstFormat } = t.params; + const srcFormatInfo = kTextureFormatInfo[srcFormat]; + const dstFormatInfo = kTextureFormatInfo[dstFormat]; + + const textureSize = { + width: lcm(srcFormatInfo.blockWidth, dstFormatInfo.blockWidth), + height: lcm(srcFormatInfo.blockHeight, dstFormatInfo.blockHeight), + depthOrArrayLayers: 1, + }; + + const srcTexture = t.device.createTexture({ + size: textureSize, + format: srcFormat, + usage: GPUTextureUsage.COPY_SRC, + }); + + const dstTexture = t.device.createTexture({ + size: textureSize, + format: dstFormat, + usage: GPUTextureUsage.COPY_DST, + }); + + // Allow copy between compatible format textures. + const srcBaseFormat = kTextureFormatInfo[srcFormat].baseFormat ?? srcFormat; + const dstBaseFormat = kTextureFormatInfo[dstFormat].baseFormat ?? dstFormat; + const isSuccess = srcBaseFormat === dstBaseFormat; + + t.TestCopyTextureToTexture( + { texture: srcTexture }, + { texture: dstTexture }, + textureSize, + isSuccess ? 'Success' : 'FinishError' + ); + }); + +g.test('depth_stencil_copy_restrictions') + .desc( + ` +Test that depth textures subresources must be entirely copied in copyTextureToTexture +- for various depth-stencil formats +- for various copy origin and size offsets +- for various source and destination texture sizes +- for various source and destination mip levels + +Note: this is only tested for 2D textures as it is the only dimension compatible with depth-stencil. +` + ) + .params(u => + u + .combine('format', kDepthStencilFormats) + .beginSubcases() + .combine('copyBoxOffsets', [ + { x: 0, y: 0, width: 0, height: 0 }, + { x: 1, y: 0, width: 0, height: 0 }, + { x: 0, y: 1, width: 0, height: 0 }, + { x: 0, y: 0, width: -1, height: 0 }, + { x: 0, y: 0, width: 0, height: -1 }, + ]) + .combine('srcTextureSize', [ + { width: 64, height: 64, depthOrArrayLayers: 1 }, + { width: 64, height: 32, depthOrArrayLayers: 1 }, + { width: 32, height: 32, depthOrArrayLayers: 1 }, + ]) + .combine('dstTextureSize', [ + { width: 64, height: 64, depthOrArrayLayers: 1 }, + { width: 64, height: 32, depthOrArrayLayers: 1 }, + { width: 32, height: 32, depthOrArrayLayers: 1 }, + ]) + .combine('srcCopyLevel', [1, 2]) + .combine('dstCopyLevel', [0, 1]) + ) + .beforeAllSubcases(t => { + const { format } = t.params; + t.selectDeviceOrSkipTestCase(kTextureFormatInfo[format].feature); + }) + .fn(async t => { + const { + format, + copyBoxOffsets, + srcTextureSize, + dstTextureSize, + srcCopyLevel, + dstCopyLevel, + } = t.params; + const kMipLevelCount = 3; + + const srcTexture = t.device.createTexture({ + size: { width: srcTextureSize.width, height: srcTextureSize.height, depthOrArrayLayers: 1 }, + format, + mipLevelCount: kMipLevelCount, + usage: GPUTextureUsage.COPY_SRC, + }); + const dstTexture = t.device.createTexture({ + size: { width: dstTextureSize.width, height: dstTextureSize.height, depthOrArrayLayers: 1 }, + format, + mipLevelCount: kMipLevelCount, + usage: GPUTextureUsage.COPY_DST, + }); + + const srcSizeAtLevel = t.GetPhysicalSubresourceSize('2d', srcTextureSize, format, srcCopyLevel); + const dstSizeAtLevel = t.GetPhysicalSubresourceSize('2d', dstTextureSize, format, dstCopyLevel); + + const copyOrigin = { x: copyBoxOffsets.x, y: copyBoxOffsets.y, z: 0 }; + + const copyWidth = + Math.min(srcSizeAtLevel.width, dstSizeAtLevel.width) + copyBoxOffsets.width - copyOrigin.x; + const copyHeight = + Math.min(srcSizeAtLevel.height, dstSizeAtLevel.height) + copyBoxOffsets.height - copyOrigin.y; + + // Depth/stencil copies must copy whole subresources. + const isSuccess = + copyOrigin.x === 0 && + copyOrigin.y === 0 && + copyWidth === srcSizeAtLevel.width && + copyHeight === srcSizeAtLevel.height && + copyWidth === dstSizeAtLevel.width && + copyHeight === dstSizeAtLevel.height; + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: { x: 0, y: 0, z: 0 }, mipLevel: srcCopyLevel }, + { texture: dstTexture, origin: copyOrigin, mipLevel: dstCopyLevel }, + { width: copyWidth, height: copyHeight, depthOrArrayLayers: 1 }, + isSuccess ? 'Success' : 'FinishError' + ); + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: copyOrigin, mipLevel: srcCopyLevel }, + { texture: dstTexture, origin: { x: 0, y: 0, z: 0 }, mipLevel: dstCopyLevel }, + { width: copyWidth, height: copyHeight, depthOrArrayLayers: 1 }, + isSuccess ? 'Success' : 'FinishError' + ); + }); + +g.test('copy_ranges') + .desc( + ` +Test that copyTextureToTexture copy boxes must be in range of the subresource. +- for various dimensions +- for various offsets to a full copy for the copy origin/size +- for various copy mip levels +` + ) + .params(u => + u + .combine('dimension', kTextureDimensions) + //.beginSubcases() + .combine('copyBoxOffsets', [ + { x: 0, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 1, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 1, y: 0, z: 0, width: -1, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 1, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 1, z: 0, width: 0, height: -1, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 1, width: 0, height: 1, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 2, width: 0, height: 1, depthOrArrayLayers: 0 }, + { x: 0, y: 0, z: 0, width: 1, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 0, width: 0, height: 1, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: 1 }, + { x: 0, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: 0 }, + { x: 0, y: 0, z: 1, width: 0, height: 0, depthOrArrayLayers: -1 }, + { x: 0, y: 0, z: 2, width: 0, height: 0, depthOrArrayLayers: -1 }, + ]) + .unless( + p => + p.dimension === '1d' && + (p.copyBoxOffsets.y !== 0 || + p.copyBoxOffsets.z !== 0 || + p.copyBoxOffsets.height !== 0 || + p.copyBoxOffsets.depthOrArrayLayers !== 0) + ) + .combine('srcCopyLevel', [0, 1, 3]) + .combine('dstCopyLevel', [0, 1, 3]) + .unless(p => p.dimension === '1d' && (p.srcCopyLevel !== 0 || p.dstCopyLevel !== 0)) + ) + .fn(async t => { + const { dimension, copyBoxOffsets, srcCopyLevel, dstCopyLevel } = t.params; + + const textureSize = { width: 16, height: 8, depthOrArrayLayers: 3 }; + let mipLevelCount = 4; + if (dimension === '1d') { + mipLevelCount = 1; + textureSize.height = 1; + textureSize.depthOrArrayLayers = 1; + } + const kFormat = 'rgba8unorm'; + + const srcTexture = t.device.createTexture({ + size: textureSize, + format: kFormat, + dimension, + mipLevelCount, + usage: GPUTextureUsage.COPY_SRC, + }); + const dstTexture = t.device.createTexture({ + size: textureSize, + format: kFormat, + dimension, + mipLevelCount, + usage: GPUTextureUsage.COPY_DST, + }); + + const srcSizeAtLevel = t.GetPhysicalSubresourceSize( + dimension, + textureSize, + kFormat, + srcCopyLevel + ); + const dstSizeAtLevel = t.GetPhysicalSubresourceSize( + dimension, + textureSize, + kFormat, + dstCopyLevel + ); + + const copyOrigin = { x: copyBoxOffsets.x, y: copyBoxOffsets.y, z: copyBoxOffsets.z }; + + const copyWidth = Math.max( + Math.min(srcSizeAtLevel.width, dstSizeAtLevel.width) + copyBoxOffsets.width - copyOrigin.x, + 0 + ); + const copyHeight = Math.max( + Math.min(srcSizeAtLevel.height, dstSizeAtLevel.height) + copyBoxOffsets.height - copyOrigin.y, + 0 + ); + const copyDepth = + textureSize.depthOrArrayLayers + copyBoxOffsets.depthOrArrayLayers - copyOrigin.z; + + { + let isSuccess = + copyWidth <= srcSizeAtLevel.width && + copyHeight <= srcSizeAtLevel.height && + copyOrigin.x + copyWidth <= dstSizeAtLevel.width && + copyOrigin.y + copyHeight <= dstSizeAtLevel.height; + + if (dimension === '3d') { + isSuccess = + isSuccess && + copyDepth <= srcSizeAtLevel.depthOrArrayLayers && + copyOrigin.z + copyDepth <= dstSizeAtLevel.depthOrArrayLayers; + } else { + isSuccess = + isSuccess && + copyDepth <= textureSize.depthOrArrayLayers && + copyOrigin.z + copyDepth <= textureSize.depthOrArrayLayers; + } + + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: { x: 0, y: 0, z: 0 }, mipLevel: srcCopyLevel }, + { texture: dstTexture, origin: copyOrigin, mipLevel: dstCopyLevel }, + { width: copyWidth, height: copyHeight, depthOrArrayLayers: copyDepth }, + isSuccess ? 'Success' : 'FinishError' + ); + } + + { + let isSuccess = + copyOrigin.x + copyWidth <= srcSizeAtLevel.width && + copyOrigin.y + copyHeight <= srcSizeAtLevel.height && + copyWidth <= dstSizeAtLevel.width && + copyHeight <= dstSizeAtLevel.height; + + if (dimension === '3d') { + isSuccess = + isSuccess && + copyDepth <= dstSizeAtLevel.depthOrArrayLayers && + copyOrigin.z + copyDepth <= srcSizeAtLevel.depthOrArrayLayers; + } else { + isSuccess = + isSuccess && + copyDepth <= textureSize.depthOrArrayLayers && + copyOrigin.z + copyDepth <= textureSize.depthOrArrayLayers; + } + + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: copyOrigin, mipLevel: srcCopyLevel }, + { texture: dstTexture, origin: { x: 0, y: 0, z: 0 }, mipLevel: dstCopyLevel }, + { width: copyWidth, height: copyHeight, depthOrArrayLayers: copyDepth }, + isSuccess ? 'Success' : 'FinishError' + ); + } + }); + +g.test('copy_within_same_texture') + .desc( + ` +Test that it is an error to use copyTextureToTexture from one subresource to itself. +- for various starting source/destination array layers. +- for various copy sizes in number of array layers + +TODO: Extend to check the copy is allowed between different mip levels. +TODO: Extend to 1D and 3D textures.` + ) + .paramsSubcasesOnly(u => + u // + .combine('srcCopyOriginZ', [0, 2, 4]) + .combine('dstCopyOriginZ', [0, 2, 4]) + .combine('copyExtentDepth', [1, 2, 3]) + ) + .fn(async t => { + const { srcCopyOriginZ, dstCopyOriginZ, copyExtentDepth } = t.params; + + const kArrayLayerCount = 7; + + const testTexture = t.device.createTexture({ + size: { width: 16, height: 16, depthOrArrayLayers: kArrayLayerCount }, + format: 'rgba8unorm', + usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.COPY_DST, + }); + + const isSuccess = + Math.min(srcCopyOriginZ, dstCopyOriginZ) + copyExtentDepth <= + Math.max(srcCopyOriginZ, dstCopyOriginZ); + t.TestCopyTextureToTexture( + { texture: testTexture, origin: { x: 0, y: 0, z: srcCopyOriginZ } }, + { texture: testTexture, origin: { x: 0, y: 0, z: dstCopyOriginZ } }, + { width: 16, height: 16, depthOrArrayLayers: copyExtentDepth }, + isSuccess ? 'Success' : 'FinishError' + ); + }); + +g.test('copy_aspects') + .desc( + ` +Test the validations on the member 'aspect' of GPUImageCopyTexture in CopyTextureToTexture(). +- for all the color and depth-stencil formats: the texture copy aspects must be both 'all'. +- for all the depth-only formats: the texture copy aspects must be either 'all' or 'depth-only'. +- for all the stencil-only formats: the texture copy aspects must be either 'all' or 'stencil-only'. +` + ) + .params(u => + u + .combine('format', ['rgba8unorm', ...kDepthStencilFormats] as const) + .beginSubcases() + .combine('sourceAspect', ['all', 'depth-only', 'stencil-only'] as const) + .combine('destinationAspect', ['all', 'depth-only', 'stencil-only'] as const) + ) + .beforeAllSubcases(t => { + const { format } = t.params; + t.selectDeviceOrSkipTestCase(kTextureFormatInfo[format].feature); + }) + .fn(async t => { + const { format, sourceAspect, destinationAspect } = t.params; + + const kTextureSize = { width: 16, height: 8, depthOrArrayLayers: 1 }; + + const srcTexture = t.device.createTexture({ + size: kTextureSize, + format, + usage: GPUTextureUsage.COPY_SRC, + }); + const dstTexture = t.device.createTexture({ + size: kTextureSize, + format, + usage: GPUTextureUsage.COPY_DST, + }); + + // MAINTENANCE_TODO: get the valid aspects from capability_info.ts. + const kValidAspectsForFormat = { + rgba8unorm: ['all'], + + // kUnsizedDepthStencilFormats + depth24plus: ['all', 'depth-only'], + 'depth24plus-stencil8': ['all'], + 'depth32float-stencil8': ['all'], + + // kSizedDepthStencilFormats + depth32float: ['all', 'depth-only'], + stencil8: ['all', 'stencil-only'], + depth16unorm: ['all', 'depth-only'], + }; + + const isSourceAspectValid = kValidAspectsForFormat[format].includes(sourceAspect); + const isDestinationAspectValid = kValidAspectsForFormat[format].includes(destinationAspect); + + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: { x: 0, y: 0, z: 0 }, aspect: sourceAspect }, + { texture: dstTexture, origin: { x: 0, y: 0, z: 0 }, aspect: destinationAspect }, + kTextureSize, + isSourceAspectValid && isDestinationAspectValid ? 'Success' : 'FinishError' + ); + }); + +g.test('copy_ranges_with_compressed_texture_formats') + .desc( + ` +Test that copyTextureToTexture copy boxes must be in range of the subresource and aligned to the block size +- for various dimensions +- for various offsets to a full copy for the copy origin/size +- for various copy mip levels + +TODO: Express the offsets in "block size" so as to be able to test non-4x4 compressed formats +` + ) + .params(u => + u + .combine('format', kCompressedTextureFormats) + .combine('dimension', kTextureDimensions) + .filter(({ dimension, format }) => textureDimensionAndFormatCompatible(dimension, format)) + .beginSubcases() + .combine('copyBoxOffsets', [ + { x: 0, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 1, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 4, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 0, width: -1, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 0, width: -4, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 1, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 4, z: 0, width: 0, height: 0, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 0, width: 0, height: -1, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 0, width: 0, height: -4, depthOrArrayLayers: -2 }, + { x: 0, y: 0, z: 0, width: 0, height: 0, depthOrArrayLayers: 0 }, + { x: 0, y: 0, z: 1, width: 0, height: 0, depthOrArrayLayers: -1 }, + ]) + .combine('srcCopyLevel', [0, 1, 2]) + .combine('dstCopyLevel', [0, 1, 2]) + ) + .beforeAllSubcases(t => { + const { format } = t.params; + t.selectDeviceOrSkipTestCase(kTextureFormatInfo[format].feature); + }) + .fn(async t => { + const { format, dimension, copyBoxOffsets, srcCopyLevel, dstCopyLevel } = t.params; + const { blockWidth, blockHeight } = kTextureFormatInfo[format]; + + const kTextureSize = { + width: 15 * blockWidth, + height: 12 * blockHeight, + depthOrArrayLayers: 3, + }; + const kMipLevelCount = 4; + + const srcTexture = t.device.createTexture({ + size: kTextureSize, + format, + dimension, + mipLevelCount: kMipLevelCount, + usage: GPUTextureUsage.COPY_SRC, + }); + const dstTexture = t.device.createTexture({ + size: kTextureSize, + format, + dimension, + mipLevelCount: kMipLevelCount, + usage: GPUTextureUsage.COPY_DST, + }); + + const srcSizeAtLevel = t.GetPhysicalSubresourceSize( + dimension, + kTextureSize, + format, + srcCopyLevel + ); + const dstSizeAtLevel = t.GetPhysicalSubresourceSize( + dimension, + kTextureSize, + format, + dstCopyLevel + ); + + const copyOrigin = { x: copyBoxOffsets.x, y: copyBoxOffsets.y, z: copyBoxOffsets.z }; + + const copyWidth = Math.max( + Math.min(srcSizeAtLevel.width, dstSizeAtLevel.width) + copyBoxOffsets.width - copyOrigin.x, + 0 + ); + const copyHeight = Math.max( + Math.min(srcSizeAtLevel.height, dstSizeAtLevel.height) + copyBoxOffsets.height - copyOrigin.y, + 0 + ); + const copyDepth = + kTextureSize.depthOrArrayLayers + copyBoxOffsets.depthOrArrayLayers - copyOrigin.z; + + const texelBlockWidth = kTextureFormatInfo[format].blockWidth; + const texelBlockHeight = kTextureFormatInfo[format].blockHeight; + + const isSuccessForCompressedFormats = + copyOrigin.x % texelBlockWidth === 0 && + copyOrigin.y % texelBlockHeight === 0 && + copyWidth % texelBlockWidth === 0 && + copyHeight % texelBlockHeight === 0; + + { + const isSuccess = + isSuccessForCompressedFormats && + copyWidth <= srcSizeAtLevel.width && + copyHeight <= srcSizeAtLevel.height && + copyOrigin.x + copyWidth <= dstSizeAtLevel.width && + copyOrigin.y + copyHeight <= dstSizeAtLevel.height && + copyOrigin.z + copyDepth <= kTextureSize.depthOrArrayLayers; + + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: { x: 0, y: 0, z: 0 }, mipLevel: srcCopyLevel }, + { texture: dstTexture, origin: copyOrigin, mipLevel: dstCopyLevel }, + { width: copyWidth, height: copyHeight, depthOrArrayLayers: copyDepth }, + isSuccess ? 'Success' : 'FinishError' + ); + } + + { + const isSuccess = + isSuccessForCompressedFormats && + copyOrigin.x + copyWidth <= srcSizeAtLevel.width && + copyOrigin.y + copyHeight <= srcSizeAtLevel.height && + copyWidth <= dstSizeAtLevel.width && + copyHeight <= dstSizeAtLevel.height && + copyOrigin.z + copyDepth <= kTextureSize.depthOrArrayLayers; + + t.TestCopyTextureToTexture( + { texture: srcTexture, origin: copyOrigin, mipLevel: srcCopyLevel }, + { texture: dstTexture, origin: { x: 0, y: 0, z: 0 }, mipLevel: dstCopyLevel }, + { width: copyWidth, height: copyHeight, depthOrArrayLayers: copyDepth }, + isSuccess ? 'Success' : 'FinishError' + ); + } + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/debug.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/debug.spec.ts new file mode 100644 index 0000000000..c8a3bdbbe4 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/debug.spec.ts @@ -0,0 +1,64 @@ +export const description = ` +API validation test for debug groups and markers + +Test Coverage: + - For each encoder type (GPUCommandEncoder, GPUComputeEncoder, GPURenderPassEncoder, + GPURenderBundleEncoder): + - Test that all pushDebugGroup must have a corresponding popDebugGroup + - Push and pop counts of 0, 1, and 2 will be used. + - An error must be generated for non matching counts. + - Test calling pushDebugGroup with empty and non-empty strings. + - Test inserting a debug marker with empty and non-empty strings. +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { kEncoderTypes } from '../../../../util/command_buffer_maker.js'; +import { ValidationTest } from '../../validation_test.js'; + +export const g = makeTestGroup(ValidationTest); + +g.test('debug_group_balanced') + .params(u => + u + .combine('encoderType', kEncoderTypes) + .beginSubcases() + .combine('pushCount', [0, 1, 2]) + .combine('popCount', [0, 1, 2]) + ) + .fn(t => { + const { encoder, validateFinishAndSubmit } = t.createEncoder(t.params.encoderType); + for (let i = 0; i < t.params.pushCount; ++i) { + encoder.pushDebugGroup(`${i}`); + } + for (let i = 0; i < t.params.popCount; ++i) { + encoder.popDebugGroup(); + } + validateFinishAndSubmit(t.params.pushCount === t.params.popCount, true); + }); + +g.test('debug_group') + .params(u => + u // + .combine('encoderType', kEncoderTypes) + .beginSubcases() + .combine('label', ['', 'group']) + ) + .fn(t => { + const { encoder, validateFinishAndSubmit } = t.createEncoder(t.params.encoderType); + encoder.pushDebugGroup(t.params.label); + encoder.popDebugGroup(); + validateFinishAndSubmit(true, true); + }); + +g.test('debug_marker') + .params(u => + u // + .combine('encoderType', kEncoderTypes) + .beginSubcases() + .combine('label', ['', 'marker']) + ) + .fn(t => { + const { encoder, validateFinishAndSubmit } = t.createEncoder(t.params.encoderType); + encoder.insertDebugMarker(t.params.label); + validateFinishAndSubmit(true, true); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/index_access.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/index_access.spec.ts new file mode 100644 index 0000000000..cdd7159d15 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/index_access.spec.ts @@ -0,0 +1,162 @@ +export const description = ` +Validation tests for indexed draws accessing the index buffer. +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { ValidationTest } from '../../validation_test.js'; + +class F extends ValidationTest { + createIndexBuffer(indexData: Iterable<number>): GPUBuffer { + return this.makeBufferWithContents(new Uint32Array(indexData), GPUBufferUsage.INDEX); + } + + createRenderPipeline(): GPURenderPipeline { + return this.device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: this.device.createShaderModule({ + code: ` + @vertex fn main() -> @builtin(position) vec4<f32> { + return vec4<f32>(0.0, 0.0, 0.0, 1.0); + }`, + }), + entryPoint: 'main', + }, + fragment: { + module: this.device.createShaderModule({ + code: ` + @fragment fn main() -> @location(0) vec4<f32> { + return vec4<f32>(0.0, 1.0, 0.0, 1.0); + }`, + }), + entryPoint: 'main', + targets: [{ format: 'rgba8unorm' }], + }, + primitive: { + topology: 'triangle-strip', + stripIndexFormat: 'uint32', + }, + }); + } + + beginRenderPass(encoder: GPUCommandEncoder) { + const colorAttachment = this.device.createTexture({ + format: 'rgba8unorm', + size: { width: 1, height: 1, depthOrArrayLayers: 1 }, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + return encoder.beginRenderPass({ + colorAttachments: [ + { + view: colorAttachment.createView(), + clearValue: { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + } + + drawIndexed( + indexBuffer: GPUBuffer, + indexCount: number, + instanceCount: number, + firstIndex: number, + baseVertex: number, + firstInstance: number, + isSuccess: boolean + ) { + const pipeline = this.createRenderPipeline(); + + const encoder = this.device.createCommandEncoder(); + const pass = this.beginRenderPass(encoder); + pass.setPipeline(pipeline); + pass.setIndexBuffer(indexBuffer, 'uint32'); + pass.drawIndexed(indexCount, instanceCount, firstIndex, baseVertex, firstInstance); + pass.end(); + + if (isSuccess) { + this.device.queue.submit([encoder.finish()]); + } else { + this.expectValidationError(() => { + encoder.finish(); + }); + } + } +} + +export const g = makeTestGroup(F); + +g.test('out_of_bounds') + .desc( + `Test drawing with out of bound index access to make sure encoder validation catch the + following indexCount and firstIndex OOB conditions + - either is within bound but indexCount + firstIndex is out of bound + - only firstIndex is out of bound + - only indexCount is out of bound + - firstIndex much larger than indexCount + - indexCount much larger than firstIndex + - max uint32 value for both to make sure the sum doesn't overflow + - max uint32 indexCount and small firstIndex + - max uint32 firstIndex and small indexCount + Together with normal and large instanceCount` + ) + .params( + u => + u + .combineWithParams([ + { indexCount: 6, firstIndex: 0 }, // draw all 6 out of 6 index + { indexCount: 5, firstIndex: 1 }, // draw the last 5 out of 6 index + { indexCount: 1, firstIndex: 5 }, // draw the last 1 out of 6 index + { indexCount: 0, firstIndex: 6 }, // firstIndex point to the one after last, but (indexCount + firstIndex) * stride <= bufferSize, valid + { indexCount: 0, firstIndex: 7 }, // (indexCount + firstIndex) * stride > bufferSize, invalid + { indexCount: 7, firstIndex: 0 }, // only indexCount out of bound + { indexCount: 6, firstIndex: 1 }, // indexCount + firstIndex out of bound + { indexCount: 1, firstIndex: 6 }, // indexCount valid, but (indexCount + firstIndex) out of bound + { indexCount: 6, firstIndex: 10000 }, // firstIndex much larger than the bound + { indexCount: 10000, firstIndex: 0 }, // indexCount much larger than the bound + { indexCount: 0xffffffff, firstIndex: 0xffffffff }, // max uint32 value + { indexCount: 0xffffffff, firstIndex: 2 }, // max uint32 indexCount and small firstIndex + { indexCount: 2, firstIndex: 0xffffffff }, // small indexCount and max uint32 firstIndex + ] as const) + .combine('instanceCount', [1, 10000]) // normal and large instanceCount + ) + .fn(t => { + const { indexCount, firstIndex, instanceCount } = t.params; + + const indexBuffer = t.createIndexBuffer([0, 1, 2, 3, 1, 2]); + const isSuccess: boolean = indexCount + firstIndex <= 6; + + t.drawIndexed(indexBuffer, indexCount, instanceCount, firstIndex, 0, 0, isSuccess); + }); + +g.test('out_of_bounds_zero_sized_index_buffer') + .desc( + `Test drawing with an empty index buffer to make sure the encoder validation catch the + following indexCount and firstIndex conditions + - indexCount + firstIndex is out of bound + - indexCount is 0 but firstIndex is out of bound + - only indexCount is out of bound + - both are 0s (not out of bound) but index buffer size is 0 + Together with normal and large instanceCount` + ) + .params( + u => + u + .combineWithParams([ + { indexCount: 3, firstIndex: 1 }, // indexCount + firstIndex out of bound + { indexCount: 0, firstIndex: 1 }, // indexCount is 0 but firstIndex out of bound + { indexCount: 3, firstIndex: 0 }, // only indexCount out of bound + { indexCount: 0, firstIndex: 0 }, // just zeros, valid + ] as const) + .combine('instanceCount', [1, 10000]) // normal and large instanceCount + ) + .fn(t => { + const { indexCount, firstIndex, instanceCount } = t.params; + + const indexBuffer = t.createIndexBuffer([]); + const isSuccess: boolean = indexCount + firstIndex <= 0; + + t.drawIndexed(indexBuffer, indexCount, instanceCount, firstIndex, 0, 0, isSuccess); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/draw.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/draw.spec.ts new file mode 100644 index 0000000000..913ea86f33 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/draw.spec.ts @@ -0,0 +1,862 @@ +export const description = ` +Here we test the validation for draw functions, mainly the buffer access validation. All four types +of draw calls are tested, and test that validation errors do / don't occur for certain call type +and parameters as expect. +`; + +import { makeTestGroup } from '../../../../../../common/framework/test_group.js'; +import { kVertexFormatInfo } from '../../../../../capability_info.js'; +import { GPUTest } from '../../../../../gpu_test.js'; +import { ValidationTest } from '../../../validation_test.js'; + +type VertexAttrib<A> = A & { shaderLocation: number }; +type VertexBuffer<V, A> = V & { + slot: number; + attributes: VertexAttrib<A>[]; +}; +type VertexState<V, A> = VertexBuffer<V, A>[]; + +type VertexLayoutState<V, A> = VertexState< + { stepMode: GPUVertexStepMode; arrayStride: number } & V, + { format: GPUVertexFormat; offset: number } & A +>; + +interface DrawIndexedParameter { + indexCount: number; + instanceCount?: number; + firstIndex?: number; + baseVertex?: number; + firstInstance?: number; +} + +function callDrawIndexed( + test: GPUTest, + encoder: GPURenderCommandsMixin, + drawType: 'drawIndexed' | 'drawIndexedIndirect', + param: DrawIndexedParameter +) { + switch (drawType) { + case 'drawIndexed': { + encoder.drawIndexed( + param.indexCount, + param.instanceCount ?? 1, + param.firstIndex ?? 0, + param.baseVertex ?? 0, + param.firstInstance ?? 0 + ); + break; + } + case 'drawIndexedIndirect': { + const indirectArray = new Int32Array([ + param.indexCount, + param.instanceCount ?? 1, + param.firstIndex ?? 0, + param.baseVertex ?? 0, + param.firstInstance ?? 0, + ]); + const indirectBuffer = test.makeBufferWithContents(indirectArray, GPUBufferUsage.INDIRECT); + encoder.drawIndexedIndirect(indirectBuffer, 0); + break; + } + } +} +interface DrawParameter { + vertexCount: number; + instanceCount?: number; + firstVertex?: number; + firstInstance?: number; +} + +function callDraw( + test: GPUTest, + encoder: GPURenderCommandsMixin, + drawType: 'draw' | 'drawIndirect', + param: DrawParameter +) { + switch (drawType) { + case 'draw': { + encoder.draw( + param.vertexCount, + param.instanceCount ?? 1, + param.firstVertex ?? 0, + param.firstInstance ?? 0 + ); + break; + } + case 'drawIndirect': { + const indirectArray = new Int32Array([ + param.vertexCount, + param.instanceCount ?? 1, + param.firstVertex ?? 0, + param.firstInstance ?? 0, + ]); + const indirectBuffer = test.makeBufferWithContents(indirectArray, GPUBufferUsage.INDIRECT); + encoder.drawIndirect(indirectBuffer, 0); + break; + } + } +} + +function makeTestPipeline( + test: ValidationTest, + buffers: VertexState< + { stepMode: GPUVertexStepMode; arrayStride: number }, + { + offset: number; + format: GPUVertexFormat; + } + > +): GPURenderPipeline { + const bufferLayouts: GPUVertexBufferLayout[] = []; + for (const b of buffers) { + bufferLayouts[b.slot] = b; + } + + return test.device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: test.device.createShaderModule({ + code: test.getNoOpShaderCode('VERTEX'), + }), + entryPoint: 'main', + buffers: bufferLayouts, + }, + fragment: { + module: test.device.createShaderModule({ + code: test.getNoOpShaderCode('FRAGMENT'), + }), + entryPoint: 'main', + targets: [{ format: 'rgba8unorm', writeMask: 0 }], + }, + primitive: { topology: 'triangle-list' }, + }); +} + +function makeTestPipelineWithVertexAndInstanceBuffer( + test: ValidationTest, + arrayStride: number, + attributeFormat: GPUVertexFormat, + attributeOffset: number = 0 +): GPURenderPipeline { + const vertexBufferLayouts: VertexLayoutState<{}, {}> = [ + { + slot: 1, + stepMode: 'vertex', + arrayStride, + attributes: [ + { + shaderLocation: 2, + format: attributeFormat, + offset: attributeOffset, + }, + ], + }, + { + slot: 7, + stepMode: 'instance', + arrayStride, + attributes: [ + { + shaderLocation: 6, + format: attributeFormat, + offset: attributeOffset, + }, + ], + }, + ]; + + return makeTestPipeline(test, vertexBufferLayouts); +} + +// Default parameters for all kind of draw call, arbitrary non-zero values that is not very large. +const kDefaultParameterForDraw = { + instanceCount: 100, + firstInstance: 100, +}; + +// Default parameters for non-indexed draw, arbitrary non-zero values that is not very large. +const kDefaultParameterForNonIndexedDraw = { + vertexCount: 100, + firstVertex: 100, +}; + +// Default parameters for indexed draw call and required index buffer, arbitrary non-zero values +// that is not very large. +const kDefaultParameterForIndexedDraw = { + indexCount: 100, + firstIndex: 100, + baseVertex: 100, + indexFormat: 'uint16' as GPUIndexFormat, + indexBufferSize: 2 * 200, // exact required bound size for index buffer +}; + +export const g = makeTestGroup(ValidationTest); + +g.test(`unused_buffer_bound`) + .desc( + ` +In this test we test that a small buffer bound to unused buffer slot won't cause validation error. +- All draw commands, + - An unused {index , vertex} buffer with uselessly small range is bound (immediately before draw + call) +` + ) + .params(u => + u // + .combine('smallIndexBuffer', [false, true]) + .combine('smallVertexBuffer', [false, true]) + .combine('smallInstanceBuffer', [false, true]) + .beginSubcases() + .combine('drawType', ['draw', 'drawIndexed', 'drawIndirect', 'drawIndexedIndirect'] as const) + .unless( + // Always provide index buffer of enough size if it is used by indexed draw + p => + p.smallIndexBuffer && + (p.drawType === 'drawIndexed' || p.drawType === 'drawIndexedIndirect') + ) + .combine('bufferOffset', [0, 4]) + .combine('boundSize', [0, 1]) + ) + .fn(async t => { + const { + smallIndexBuffer, + smallVertexBuffer, + smallInstanceBuffer, + drawType, + bufferOffset, + boundSize, + } = t.params; + const renderPipeline = t.createNoOpRenderPipeline(); + const bufferSize = bufferOffset + boundSize; + const smallBuffer = t.createBufferWithState('valid', { + size: bufferSize, + usage: GPUBufferUsage.INDEX | GPUBufferUsage.VERTEX, + }); + + // An index buffer of enough size, used if smallIndexBuffer === false + const { indexFormat, indexBufferSize } = kDefaultParameterForIndexedDraw; + const indexBuffer = t.createBufferWithState('valid', { + size: indexBufferSize, + usage: GPUBufferUsage.INDEX, + }); + + for (const encoderType of ['render bundle', 'render pass'] as const) { + for (const setPipelineBeforeBuffer of [false, true]) { + const commandBufferMaker = t.createEncoder(encoderType); + const renderEncoder = commandBufferMaker.encoder; + + if (setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + + if (drawType === 'drawIndexed' || drawType === 'drawIndexedIndirect') { + // Always use large enough index buffer for indexed draw. Index buffer OOB validation is + // tested in index_buffer_OOB. + renderEncoder.setIndexBuffer(indexBuffer, indexFormat, 0, indexBufferSize); + } else if (smallIndexBuffer) { + renderEncoder.setIndexBuffer(smallBuffer, indexFormat, bufferOffset, boundSize); + } + if (smallVertexBuffer) { + renderEncoder.setVertexBuffer(1, smallBuffer, bufferOffset, boundSize); + } + if (smallInstanceBuffer) { + renderEncoder.setVertexBuffer(7, smallBuffer, bufferOffset, boundSize); + } + + if (!setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + + if (drawType === 'draw' || drawType === 'drawIndirect') { + const drawParam: DrawParameter = { + ...kDefaultParameterForDraw, + ...kDefaultParameterForNonIndexedDraw, + }; + callDraw(t, renderEncoder, drawType, drawParam); + } else { + const drawParam: DrawIndexedParameter = { + ...kDefaultParameterForDraw, + ...kDefaultParameterForIndexedDraw, + }; + callDrawIndexed(t, renderEncoder, drawType, drawParam); + } + + // Binding a unused small index/vertex buffer will never cause validation error. + commandBufferMaker.validateFinishAndSubmit(true, true); + } + } + }); + +g.test(`index_buffer_OOB`) + .desc( + ` +In this test we test that index buffer OOB is caught as a validation error in drawIndexed, but not in +drawIndexedIndirect as it is GPU-validated. +- Issue an indexed draw call, with the following index buffer states, for {all index formats}: + - range and GPUBuffer are exactly the required size for the draw call + - range is too small but GPUBuffer is still large enough + - range and GPUBuffer are both too small +` + ) + .params(u => + u + .combine('bufferSizeInElements', [10, 100]) + // Binding size is always no larger than buffer size, make sure that setIndexBuffer succeed + .combine('bindingSizeInElements', [10]) + .combine('drawIndexCount', [10, 11]) + .combine('drawType', ['drawIndexed', 'drawIndexedIndirect'] as const) + .beginSubcases() + .combine('indexFormat', ['uint16', 'uint32'] as GPUIndexFormat[]) + ) + .fn(async t => { + const { + indexFormat, + bindingSizeInElements, + bufferSizeInElements, + drawIndexCount, + drawType, + } = t.params; + + const indexElementSize = indexFormat === 'uint16' ? 2 : 4; + const bindingSize = bindingSizeInElements * indexElementSize; + const bufferSize = bufferSizeInElements * indexElementSize; + + const desc: GPUBufferDescriptor = { + size: bufferSize, + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }; + const indexBuffer = t.createBufferWithState('valid', desc); + + const drawCallParam: DrawIndexedParameter = { + indexCount: drawIndexCount, + }; + + // Encoder finish will succeed if no index buffer access OOB when calling drawIndexed, + // and always succeed when calling drawIndexedIndirect. + const isFinishSuccess = + drawIndexCount <= bindingSizeInElements || drawType === 'drawIndexedIndirect'; + + const renderPipeline = t.createNoOpRenderPipeline(); + + for (const encoderType of ['render bundle', 'render pass'] as const) { + for (const setPipelineBeforeBuffer of [false, true]) { + const commandBufferMaker = t.createEncoder(encoderType); + const renderEncoder = commandBufferMaker.encoder; + + if (setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + renderEncoder.setIndexBuffer(indexBuffer, indexFormat, 0, bindingSize); + if (!setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + + callDrawIndexed(t, renderEncoder, drawType, drawCallParam); + + commandBufferMaker.validateFinishAndSubmit(isFinishSuccess, true); + } + } + }); + +g.test(`vertex_buffer_OOB`) + .desc( + ` +In this test we test the vertex buffer OOB validation in draw calls. Specifically, only vertex step +mode buffer OOB in draw and instance step mode buffer OOB in draw and drawIndexed are CPU-validated. +Other cases are handled by robust access and no validation error occurs. +- Test that: + - Draw call needs to read {=, >} any bound vertex buffer range, with GPUBuffer that is {large + enough, exactly the size of bound range} + - Binding size = 0 (ensure it's not treated as a special case) + - x= weird buffer offset values + - x= weird attribute offset values + - x= weird arrayStride values + - x= {render pass, render bundle} +- For vertex step mode vertex buffer, + - Test that: + - vertexCount largeish + - firstVertex {=, >} 0 + - arrayStride is 0 and bound buffer size too small + - (vertexCount + firstVertex) is zero + - Validation error occurs in: + - draw + - drawIndexed with a zero array stride vertex step mode buffer OOB + - Otherwise no validation error in drawIndexed, draIndirect and drawIndexedIndirect +- For instance step mode vertex buffer, + - Test with draw and drawIndexed: + - instanceCount largeish + - firstInstance {=, >} 0 + - arrayStride is 0 and bound buffer size too small + - (instanceCount + firstInstance) is zero + - Validation error occurs in draw and drawIndexed + - No validation error in drawIndirect and drawIndexedIndirect + +In this test, we use a a render pipeline requiring one vertex step mode with different vertex buffer +layouts (attribute offset, array stride, vertex format). Then for a given drawing parameter set (e.g., +vertexCount, instanceCount, firstVertex, indexCount), we calculate the exactly required size for +vertex step mode vertex buffer. Then, we generate buffer parameters (i.e. GPU buffer size, +binding offset and binding size) for all buffers, covering both (bound size == required size), +(bound size == required size - 1), and (bound size == 0), and test that draw and drawIndexed will +success/error as expected. Such set of buffer parameters should include cases like weird offset values. +` + ) + .params(u => + u + // type of draw call + .combine('type', ['draw', 'drawIndexed', 'drawIndirect', 'drawIndexedIndirect'] as const) + // the state of vertex step mode vertex buffer bound size + .combine('VBSize', ['zero', 'exile', 'enough'] as const) + // the state of instance step mode vertex buffer bound size + .combine('IBSize', ['zero', 'exile', 'enough'] as const) + // should the vertex stride count be zero + .combine('VStride0', [false, true] as const) + // should the instance stride count be zero + .combine('IStride0', [false, true] as const) + // the state of array stride + .combine('AStride', ['zero', 'exact', 'oversize'] as const) + // the factor for offset of attributes in vertex layout + .combine('offset', [0, 1, 2, 7]) // the offset of attribute will be factor * MIN(4, sizeof(vertexFormat)) + .beginSubcases() + .combine('setBufferOffset', [0, 200]) // must be a multiple of 4 + .combine('attributeFormat', ['snorm8x2', 'float32', 'float16x4'] as GPUVertexFormat[]) + .combine('vertexCount', [0, 1, 10000]) + .combine('firstVertex', [0, 10000]) + .filter(p => p.VStride0 === (p.firstVertex + p.vertexCount === 0)) + .combine('instanceCount', [0, 1, 10000]) + .combine('firstInstance', [0, 10000]) + .filter(p => p.IStride0 === (p.firstInstance + p.instanceCount === 0)) + .unless(p => p.vertexCount === 10000 && p.instanceCount === 10000) + ) + .fn(async t => { + const { + type: drawType, + VBSize: boundVertexBufferSizeState, + IBSize: boundInstanceBufferSizeState, + VStride0: zeroVertexStrideCount, + IStride0: zeroInstanceStrideCount, + AStride: arrayStrideState, + offset: attributeOffsetFactor, + setBufferOffset, + attributeFormat, + vertexCount, + instanceCount, + firstVertex, + firstInstance, + } = t.params; + + const attributeFormatInfo = kVertexFormatInfo[attributeFormat]; + const formatSize = attributeFormatInfo.bytesPerComponent * attributeFormatInfo.componentCount; + const attributeOffset = attributeOffsetFactor * Math.min(4, formatSize); + const lastStride = attributeOffset + formatSize; + let arrayStride = 0; + if (arrayStrideState !== 'zero') { + arrayStride = lastStride; + if (arrayStrideState === 'oversize') { + // Add an arbitrary number to array stride to make it larger than required by attributes + arrayStride = arrayStride + 20; + } + arrayStride = arrayStride + (-arrayStride & 3); // Make sure arrayStride is a multiple of 4 + } + + const calcSetBufferSize = ( + boundBufferSizeState: 'zero' | 'exile' | 'enough', + strideCount: number + ): number => { + let requiredBufferSize: number; + if (strideCount > 0) { + requiredBufferSize = arrayStride * (strideCount - 1) + lastStride; + } else { + // Spec do not validate bounded buffer size if strideCount == 0. + requiredBufferSize = lastStride; + } + let setBufferSize: number; + switch (boundBufferSizeState) { + case 'zero': { + setBufferSize = 0; + break; + } + case 'exile': { + setBufferSize = requiredBufferSize - 1; + break; + } + case 'enough': { + setBufferSize = requiredBufferSize; + break; + } + } + return setBufferSize; + }; + + const strideCountForVertexBuffer = firstVertex + vertexCount; + const setVertexBufferSize = calcSetBufferSize( + boundVertexBufferSizeState, + strideCountForVertexBuffer + ); + const vertexBufferSize = setBufferOffset + setVertexBufferSize; + const strideCountForInstanceBuffer = firstInstance + instanceCount; + const setInstanceBufferSize = calcSetBufferSize( + boundInstanceBufferSizeState, + strideCountForInstanceBuffer + ); + const instanceBufferSize = setBufferOffset + setInstanceBufferSize; + + const vertexBuffer = t.createBufferWithState('valid', { + size: vertexBufferSize, + usage: GPUBufferUsage.VERTEX, + }); + const instanceBuffer = t.createBufferWithState('valid', { + size: instanceBufferSize, + usage: GPUBufferUsage.VERTEX, + }); + + const renderPipeline = makeTestPipelineWithVertexAndInstanceBuffer( + t, + arrayStride, + attributeFormat, + attributeOffset + ); + + for (const encoderType of ['render bundle', 'render pass'] as const) { + for (const setPipelineBeforeBuffer of [false, true]) { + const commandBufferMaker = t.createEncoder(encoderType); + const renderEncoder = commandBufferMaker.encoder; + + if (setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + renderEncoder.setVertexBuffer(1, vertexBuffer, setBufferOffset, setVertexBufferSize); + renderEncoder.setVertexBuffer(7, instanceBuffer, setBufferOffset, setInstanceBufferSize); + if (!setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + + if (drawType === 'draw' || drawType === 'drawIndirect') { + const drawParam: DrawParameter = { + vertexCount, + instanceCount, + firstVertex, + firstInstance, + }; + + callDraw(t, renderEncoder, drawType, drawParam); + } else { + const { + indexFormat, + indexCount, + firstIndex, + indexBufferSize, + } = kDefaultParameterForIndexedDraw; + + const desc: GPUBufferDescriptor = { + size: indexBufferSize, + usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, + }; + const indexBuffer = t.createBufferWithState('valid', desc); + + const drawParam: DrawIndexedParameter = { + indexCount, + instanceCount, + firstIndex, + baseVertex: firstVertex, + firstInstance, + }; + + renderEncoder.setIndexBuffer(indexBuffer, indexFormat, 0, indexBufferSize); + callDrawIndexed(t, renderEncoder, drawType, drawParam); + } + + const isVertexBufferOOB = + boundVertexBufferSizeState !== 'enough' && + drawType === 'draw' && // drawIndirect, drawIndexed, and drawIndexedIndirect do not validate vertex step mode buffer + !zeroVertexStrideCount; // vertex step mode buffer never OOB if stride count = 0 + const isInstanceBufferOOB = + boundInstanceBufferSizeState !== 'enough' && + (drawType === 'draw' || drawType === 'drawIndexed') && // drawIndirect and drawIndexedIndirect do not validate instance step mode buffer + !zeroInstanceStrideCount; // vertex step mode buffer never OOB if stride count = 0 + const isFinishSuccess = !isVertexBufferOOB && !isInstanceBufferOOB; + + commandBufferMaker.validateFinishAndSubmit(isFinishSuccess, true); + } + } + }); + +g.test(`buffer_binding_overlap`) + .desc( + ` +In this test we test that binding one GPU buffer to multiple vertex buffer slot or both vertex +buffer slot and index buffer will cause no validation error, with completely/partial overlap. + - x= all draw types +` + ) + .params(u => + u // + .combine('drawType', ['draw', 'drawIndexed', 'drawIndirect', 'drawIndexedIndirect'] as const) + .beginSubcases() + .combine('vertexBoundOffestFactor', [0, 0.5, 1, 1.5, 2]) + .combine('instanceBoundOffestFactor', [0, 0.5, 1, 1.5, 2]) + .combine('indexBoundOffestFactor', [0, 0.5, 1, 1.5, 2]) + .combine('arrayStrideState', ['zero', 'exact', 'oversize'] as const) + ) + .fn(async t => { + const { + drawType, + vertexBoundOffestFactor, + instanceBoundOffestFactor, + indexBoundOffestFactor, + arrayStrideState, + } = t.params; + + // Compute the array stride for vertex step mode and instance step mode attribute + const attributeFormat = 'float32x4'; + const attributeFormatInfo = kVertexFormatInfo[attributeFormat]; + const formatSize = attributeFormatInfo.bytesPerComponent * attributeFormatInfo.componentCount; + const attributeOffset = 0; + const lastStride = attributeOffset + formatSize; + let arrayStride = 0; + if (arrayStrideState !== 'zero') { + arrayStride = lastStride; + if (arrayStrideState === 'oversize') { + // Add an arbitrary number to array stride + arrayStride = arrayStride + 20; + } + arrayStride = arrayStride + (-arrayStride & 3); // Make sure arrayStride is a multiple of 4 + } + + const calcAttributeBufferSize = (strideCount: number): number => { + let requiredBufferSize: number; + if (strideCount > 0) { + requiredBufferSize = arrayStride * (strideCount - 1) + lastStride; + } else { + // Spec do not validate bounded buffer size if strideCount == 0. + requiredBufferSize = lastStride; + } + return requiredBufferSize; + }; + + const calcSetBufferOffset = (requiredSetBufferSize: number, offsetFactor: number): number => { + const offset = Math.ceil(requiredSetBufferSize * offsetFactor); + const alignedOffset = offset + (-offset & 3); // Make sure offset is a multiple of 4 + return alignedOffset; + }; + + // Compute required bound range for all vertex and index buffer to ensure the shared GPU buffer + // has enough size. + const { vertexCount, firstVertex } = kDefaultParameterForNonIndexedDraw; + const strideCountForVertexBuffer = firstVertex + vertexCount; + const setVertexBufferSize = calcAttributeBufferSize(strideCountForVertexBuffer); + const setVertexBufferOffset = calcSetBufferOffset(setVertexBufferSize, vertexBoundOffestFactor); + let requiredBufferSize = setVertexBufferOffset + setVertexBufferSize; + + const { instanceCount, firstInstance } = kDefaultParameterForDraw; + const strideCountForInstanceBuffer = firstInstance + instanceCount; + const setInstanceBufferSize = calcAttributeBufferSize(strideCountForInstanceBuffer); + const setInstanceBufferOffset = calcSetBufferOffset( + setInstanceBufferSize, + instanceBoundOffestFactor + ); + requiredBufferSize = Math.max( + requiredBufferSize, + setInstanceBufferOffset + setInstanceBufferSize + ); + + const { indexBufferSize: setIndexBufferSize, indexFormat } = kDefaultParameterForIndexedDraw; + const setIndexBufferOffset = calcSetBufferOffset(setIndexBufferSize, indexBoundOffestFactor); + requiredBufferSize = Math.max(requiredBufferSize, setIndexBufferOffset + setIndexBufferSize); + + // Create the shared GPU buffer with both vertetx and index usage + const sharedBuffer = t.createBufferWithState('valid', { + size: requiredBufferSize, + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.INDEX, + }); + + const renderPipeline = makeTestPipelineWithVertexAndInstanceBuffer( + t, + arrayStride, + attributeFormat + ); + + for (const encoderType of ['render bundle', 'render pass'] as const) { + for (const setPipelineBeforeBuffer of [false, true]) { + const commandBufferMaker = t.createEncoder(encoderType); + const renderEncoder = commandBufferMaker.encoder; + + if (setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + renderEncoder.setVertexBuffer(1, sharedBuffer, setVertexBufferOffset, setVertexBufferSize); + renderEncoder.setVertexBuffer( + 7, + sharedBuffer, + setInstanceBufferOffset, + setInstanceBufferSize + ); + renderEncoder.setIndexBuffer( + sharedBuffer, + indexFormat, + setIndexBufferOffset, + setIndexBufferSize + ); + if (!setPipelineBeforeBuffer) { + renderEncoder.setPipeline(renderPipeline); + } + + if (drawType === 'draw' || drawType === 'drawIndirect') { + const drawParam: DrawParameter = { + ...kDefaultParameterForDraw, + ...kDefaultParameterForNonIndexedDraw, + }; + callDraw(t, renderEncoder, drawType, drawParam); + } else { + const drawParam: DrawIndexedParameter = { + ...kDefaultParameterForDraw, + ...kDefaultParameterForIndexedDraw, + }; + callDrawIndexed(t, renderEncoder, drawType, drawParam); + } + + // Since all bound buffer are of enough size, draw call should always succeed. + commandBufferMaker.validateFinishAndSubmit(true, true); + } + } + }); + +g.test(`last_buffer_setting_take_account`) + .desc( + ` +In this test we test that only the last setting for a buffer slot take account. +- All (non/indexed, in/direct) draw commands + - setPl, setVB, setIB, draw, {setPl,setVB,setIB,nothing (control)}, then a larger draw that + wouldn't have been valid before that +` + ) + .unimplemented(); + +g.test(`max_draw_count`) + .desc( + ` +In this test we test that draw count which exceeds +GPURenderPassDescriptor.maxDrawCount causes validation error on +GPUCommandEncoder.finish(). The test sets specified maxDrawCount, +calls specified draw call specified times with or without bundles, +and checks whether GPUCommandEncoder.finish() causes a validation error. + - x= whether to use a bundle for the first half of the draw calls + - x= whether to use a bundle for the second half of the draw calls + - x= several different draw counts + - x= several different maxDrawCounts +` + ) + .params(u => + u + .combine('bundleFirstHalf', [false, true]) + .combine('bundleSecondHalf', [false, true]) + .combine('maxDrawCount', [0, 1, 4, 16]) + .beginSubcases() + .expand('drawCount', p => new Set([0, p.maxDrawCount, p.maxDrawCount + 1])) + ) + .fn(async t => { + const { bundleFirstHalf, bundleSecondHalf, maxDrawCount, drawCount } = t.params; + + const colorFormat = 'rgba8unorm'; + const colorTexture = t.device.createTexture({ + size: { width: 1, height: 1, depthOrArrayLayers: 1 }, + format: colorFormat, + mipLevelCount: 1, + sampleCount: 1, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + const pipeline = t.device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: t.device.createShaderModule({ + code: ` + @vertex fn main() -> @builtin(position) vec4<f32> { + return vec4<f32>(); + } + `, + }), + entryPoint: 'main', + }, + fragment: { + module: t.device.createShaderModule({ + code: `@fragment fn main() {}`, + }), + entryPoint: 'main', + targets: [{ format: colorFormat, writeMask: 0 }], + }, + }); + + const indexBuffer = t.makeBufferWithContents(new Uint16Array([0, 0, 0]), GPUBufferUsage.INDEX); + const indirectBuffer = t.makeBufferWithContents( + new Uint32Array([3, 1, 0, 0]), + GPUBufferUsage.INDIRECT + ); + const indexedIndirectBuffer = t.makeBufferWithContents( + new Uint32Array([3, 1, 0, 0, 0]), + GPUBufferUsage.INDIRECT + ); + + const commandEncoder = t.device.createCommandEncoder(); + const renderPassEncoder = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: colorTexture.createView(), + loadOp: 'clear', + storeOp: 'store', + }, + ], + maxDrawCount, + }); + + const firstHalfEncoder = bundleFirstHalf + ? t.device.createRenderBundleEncoder({ + colorFormats: [colorFormat], + }) + : renderPassEncoder; + + const secondHalfEncoder = bundleSecondHalf + ? t.device.createRenderBundleEncoder({ + colorFormats: [colorFormat], + }) + : renderPassEncoder; + + firstHalfEncoder.setPipeline(pipeline); + firstHalfEncoder.setIndexBuffer(indexBuffer, 'uint16'); + secondHalfEncoder.setPipeline(pipeline); + secondHalfEncoder.setIndexBuffer(indexBuffer, 'uint16'); + + const halfDrawCount = Math.floor(drawCount / 2); + for (let i = 0; i < drawCount; i++) { + const encoder = i < halfDrawCount ? firstHalfEncoder : secondHalfEncoder; + if (i % 4 === 0) { + encoder.draw(3); + } + if (i % 4 === 1) { + encoder.drawIndexed(3); + } + if (i % 4 === 2) { + encoder.drawIndirect(indirectBuffer, 0); + } + if (i % 4 === 3) { + encoder.drawIndexedIndirect(indexedIndirectBuffer, 0); + } + } + + const bundles = []; + if (bundleFirstHalf) { + bundles.push((firstHalfEncoder as GPURenderBundleEncoder).finish()); + } + if (bundleSecondHalf) { + bundles.push((secondHalfEncoder as GPURenderBundleEncoder).finish()); + } + + if (bundles.length > 0) { + renderPassEncoder.executeBundles(bundles); + } + + renderPassEncoder.end(); + + t.expectValidationError(() => { + commandEncoder.finish(); + }, drawCount > maxDrawCount); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/dynamic_state.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/dynamic_state.spec.ts new file mode 100644 index 0000000000..d7bdec6ba5 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/dynamic_state.spec.ts @@ -0,0 +1,319 @@ +export const description = ` +API validation tests for dynamic state commands (setViewport/ScissorRect/BlendColor...). + +TODO: ensure existing tests cover these notes. Note many of these may be operation tests instead. +> - setViewport +> - {x, y} = {0, invalid values if any} +> - {width, height, minDepth, maxDepth} = { +> - least possible value that's valid +> - greatest possible negative value that's invalid +> - greatest possible positive value that's valid +> - least possible positive value that's invalid if any +> - } +> - minDepth {<, =, >} maxDepth +> - setScissorRect +> - {width, height} = 0 +> - {x+width, y+height} = attachment size + 1 +> - setBlendConstant +> - color {slightly, very} out of range +> - used with a simple pipeline that {does, doesn't} use it +> - setStencilReference +> - {0, max} +> - used with a simple pipeline that {does, doesn't} use it +`; + +import { makeTestGroup } from '../../../../../../common/framework/test_group.js'; +import { ValidationTest } from '../../../validation_test.js'; + +interface ViewportCall { + x: number; + y: number; + w: number; + h: number; + minDepth: number; + maxDepth: number; +} + +interface ScissorCall { + x: number; + y: number; + w: number; + h: number; +} + +class F extends ValidationTest { + testViewportCall( + success: boolean, + v: ViewportCall, + attachmentSize: GPUExtent3D = { width: 1, height: 1, depthOrArrayLayers: 1 } + ) { + const attachment = this.device.createTexture({ + format: 'rgba8unorm', + size: attachmentSize, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: attachment.createView(), + loadOp: 'load', + storeOp: 'store', + }, + ], + }); + pass.setViewport(v.x, v.y, v.w, v.h, v.minDepth, v.maxDepth); + pass.end(); + + this.expectValidationError(() => { + encoder.finish(); + }, !success); + } + + testScissorCall( + success: boolean | 'type-error', + s: ScissorCall, + attachmentSize: GPUExtent3D = { width: 1, height: 1, depthOrArrayLayers: 1 } + ) { + const attachment = this.device.createTexture({ + format: 'rgba8unorm', + size: attachmentSize, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: attachment.createView(), + loadOp: 'load', + storeOp: 'store', + }, + ], + }); + if (success === 'type-error') { + this.shouldThrow('TypeError', () => { + pass.setScissorRect(s.x, s.y, s.w, s.h); + }); + } else { + pass.setScissorRect(s.x, s.y, s.w, s.h); + pass.end(); + + this.expectValidationError(() => { + encoder.finish(); + }, !success); + } + } + + createDummyRenderPassEncoder(): { encoder: GPUCommandEncoder; pass: GPURenderPassEncoder } { + const attachment = this.device.createTexture({ + format: 'rgba8unorm', + size: [1, 1, 1], + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + const encoder = this.device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: attachment.createView(), + loadOp: 'load', + storeOp: 'store', + }, + ], + }); + + return { encoder, pass }; + } +} + +export const g = makeTestGroup(F); + +g.test('setViewport,x_y_width_height_nonnegative') + .desc( + `Test that the parameters of setViewport to define the box must be non-negative. + +TODO Test -0 (it should be valid) but can't be tested because the harness complains about duplicate parameters. +TODO Test the first value smaller than -0` + ) + .paramsSubcasesOnly([ + // Control case: everything to 0 is ok, covers the empty viewport case. + { x: 0, y: 0, w: 0, h: 0 }, + + // Test -1 + { x: -1, y: 0, w: 0, h: 0 }, + { x: 0, y: -1, w: 0, h: 0 }, + { x: 0, y: 0, w: -1, h: 0 }, + { x: 0, y: 0, w: 0, h: -1 }, + ]) + .fn(t => { + const { x, y, w, h } = t.params; + const success = x >= 0 && y >= 0 && w >= 0 && h >= 0; + t.testViewportCall(success, { x, y, w, h, minDepth: 0, maxDepth: 1 }); + }); + +g.test('setViewport,xy_rect_contained_in_attachment') + .desc( + 'Test that the rectangle defined by x, y, width, height must be contained in the attachments' + ) + .paramsSubcasesOnly(u => + u + .combineWithParams([ + { attachmentWidth: 3, attachmentHeight: 5 }, + { attachmentWidth: 5, attachmentHeight: 3 }, + { attachmentWidth: 1024, attachmentHeight: 1 }, + { attachmentWidth: 1, attachmentHeight: 1024 }, + ]) + .combineWithParams([ + // Control case: a full viewport is valid. + { dx: 0, dy: 0, dw: 0, dh: 0 }, + + // Other valid cases with a partial viewport. + { dx: 1, dy: 0, dw: -1, dh: 0 }, + { dx: 0, dy: 1, dw: 0, dh: -1 }, + { dx: 0, dy: 0, dw: -1, dh: 0 }, + { dx: 0, dy: 0, dw: 0, dh: -1 }, + + // Test with a small value that causes the viewport to go outside the attachment. + { dx: 1, dy: 0, dw: 0, dh: 0 }, + { dx: 0, dy: 1, dw: 0, dh: 0 }, + { dx: 0, dy: 0, dw: 1, dh: 0 }, + { dx: 0, dy: 0, dw: 0, dh: 1 }, + ]) + ) + .fn(t => { + const { attachmentWidth, attachmentHeight, dx, dy, dw, dh } = t.params; + const x = dx; + const y = dy; + const w = attachmentWidth + dw; + const h = attachmentWidth + dh; + + const success = x + w <= attachmentWidth && y + h <= attachmentHeight; + t.testViewportCall( + success, + { x, y, w, h, minDepth: 0, maxDepth: 1 }, + { width: attachmentWidth, height: attachmentHeight, depthOrArrayLayers: 1 } + ); + }); + +g.test('setViewport,depth_rangeAndOrder') + .desc('Test that 0 <= minDepth <= maxDepth <= 1') + .paramsSubcasesOnly([ + // Success cases + { minDepth: 0, maxDepth: 1 }, + { minDepth: -0, maxDepth: -0 }, + { minDepth: 1, maxDepth: 1 }, + { minDepth: 0.3, maxDepth: 0.7 }, + { minDepth: 0.7, maxDepth: 0.7 }, + { minDepth: 0.3, maxDepth: 0.3 }, + + // Invalid cases + { minDepth: -0.1, maxDepth: 1 }, + { minDepth: 0, maxDepth: 1.1 }, + { minDepth: 0.5, maxDepth: 0.49999 }, + ]) + .fn(t => { + const { minDepth, maxDepth } = t.params; + const success = + 0 <= minDepth && minDepth <= 1 && 0 <= maxDepth && maxDepth <= 1 && minDepth <= maxDepth; + t.testViewportCall(success, { x: 0, y: 0, w: 1, h: 1, minDepth, maxDepth }); + }); + +g.test('setScissorRect,x_y_width_height_nonnegative') + .desc( + `Test that the parameters of setScissorRect to define the box must be non-negative or a TypeError is thrown. + +TODO Test -0 (it should be valid) but can't be tested because the harness complains about duplicate parameters. +TODO Test the first value smaller than -0` + ) + .paramsSubcasesOnly([ + // Control case: everything to 0 is ok, covers the empty scissor case. + { x: 0, y: 0, w: 0, h: 0 }, + + // Test -1 + { x: -1, y: 0, w: 0, h: 0 }, + { x: 0, y: -1, w: 0, h: 0 }, + { x: 0, y: 0, w: -1, h: 0 }, + { x: 0, y: 0, w: 0, h: -1 }, + ]) + .fn(t => { + const { x, y, w, h } = t.params; + const success = x >= 0 && y >= 0 && w >= 0 && h >= 0; + t.testScissorCall(success ? true : 'type-error', { x, y, w, h }); + }); + +g.test('setScissorRect,xy_rect_contained_in_attachment') + .desc( + 'Test that the rectangle defined by x, y, width, height must be contained in the attachments' + ) + .paramsSubcasesOnly(u => + u + .combineWithParams([ + { attachmentWidth: 3, attachmentHeight: 5 }, + { attachmentWidth: 5, attachmentHeight: 3 }, + { attachmentWidth: 1024, attachmentHeight: 1 }, + { attachmentWidth: 1, attachmentHeight: 1024 }, + ]) + .combineWithParams([ + // Control case: a full scissor is valid. + { dx: 0, dy: 0, dw: 0, dh: 0 }, + + // Other valid cases with a partial scissor. + { dx: 1, dy: 0, dw: -1, dh: 0 }, + { dx: 0, dy: 1, dw: 0, dh: -1 }, + { dx: 0, dy: 0, dw: -1, dh: 0 }, + { dx: 0, dy: 0, dw: 0, dh: -1 }, + + // Test with a small value that causes the scissor to go outside the attachment. + { dx: 1, dy: 0, dw: 0, dh: 0 }, + { dx: 0, dy: 1, dw: 0, dh: 0 }, + { dx: 0, dy: 0, dw: 1, dh: 0 }, + { dx: 0, dy: 0, dw: 0, dh: 1 }, + ]) + ) + .fn(t => { + const { attachmentWidth, attachmentHeight, dx, dy, dw, dh } = t.params; + const x = dx; + const y = dy; + const w = attachmentWidth + dw; + const h = attachmentWidth + dh; + + const success = x + w <= attachmentWidth && y + h <= attachmentHeight; + t.testScissorCall( + success, + { x, y, w, h }, + { width: attachmentWidth, height: attachmentHeight, depthOrArrayLayers: 1 } + ); + }); + +g.test('setBlendConstant') + .desc('Test that almost any color value is valid for setBlendConstant') + .paramsSubcasesOnly([ + { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, + { r: -1.0, g: -1.0, b: -1.0, a: -1.0 }, + { r: Number.MAX_SAFE_INTEGER, g: Number.MIN_SAFE_INTEGER, b: -0, a: 100000 }, + ]) + .fn(t => { + const { r, g, b, a } = t.params; + const encoders = t.createDummyRenderPassEncoder(); + encoders.pass.setBlendConstant({ r, g, b, a }); + encoders.pass.end(); + encoders.encoder.finish(); + }); + +g.test('setStencilReference') + .desc('Test that almost any stencil reference value is valid for setStencilReference') + .paramsSubcasesOnly([ + { value: 1 }, // + { value: 0 }, + { value: 1000 }, + { value: 0xffffffff }, + ]) + .fn(t => { + const { value } = t.params; + const encoders = t.createDummyRenderPassEncoder(); + encoders.pass.setStencilReference(value); + encoders.pass.end(); + encoders.encoder.finish(); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/indirect_draw.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/indirect_draw.spec.ts new file mode 100644 index 0000000000..017c1aa24f --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/indirect_draw.spec.ts @@ -0,0 +1,202 @@ +export const description = ` +Validation tests for drawIndirect/drawIndexedIndirect on render pass and render bundle. +`; + +import { makeTestGroup } from '../../../../../../common/framework/test_group.js'; +import { GPUConst } from '../../../../../constants.js'; +import { kResourceStates } from '../../../../../gpu_test.js'; +import { ValidationTest } from '../../../validation_test.js'; + +import { kRenderEncodeTypeParams } from './render.js'; + +const kIndirectDrawTestParams = kRenderEncodeTypeParams.combine('indexed', [true, false] as const); + +class F extends ValidationTest { + makeIndexBuffer(): GPUBuffer { + return this.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.INDEX, + }); + } +} + +export const g = makeTestGroup(F); + +g.test('indirect_buffer_state') + .desc( + ` +Tests indirect buffer must be valid. + ` + ) + .paramsSubcasesOnly(kIndirectDrawTestParams.combine('state', kResourceStates)) + .fn(t => { + const { encoderType, indexed, state } = t.params; + const pipeline = t.createNoOpRenderPipeline(); + const indirectBuffer = t.createBufferWithState(state, { + size: 256, + usage: GPUBufferUsage.INDIRECT, + }); + + const { encoder, validateFinishAndSubmitGivenState } = t.createEncoder(encoderType); + encoder.setPipeline(pipeline); + if (indexed) { + const indexBuffer = t.makeIndexBuffer(); + encoder.setIndexBuffer(indexBuffer, 'uint32'); + encoder.drawIndexedIndirect(indirectBuffer, 0); + } else { + encoder.drawIndirect(indirectBuffer, 0); + } + + validateFinishAndSubmitGivenState(state); + }); + +g.test('indirect_buffer,device_mismatch') + .desc( + 'Tests draw(Indexed)Indirect cannot be called with an indirect buffer created from another device' + ) + .paramsSubcasesOnly(kIndirectDrawTestParams.combine('mismatched', [true, false])) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { encoderType, indexed, mismatched } = t.params; + + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + + const indirectBuffer = sourceDevice.createBuffer({ + size: 256, + usage: GPUBufferUsage.INDIRECT, + }); + t.trackForCleanup(indirectBuffer); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setPipeline(t.createNoOpRenderPipeline()); + + if (indexed) { + encoder.setIndexBuffer(t.makeIndexBuffer(), 'uint32'); + encoder.drawIndexedIndirect(indirectBuffer, 0); + } else { + encoder.drawIndirect(indirectBuffer, 0); + } + validateFinish(!mismatched); + }); + +g.test('indirect_buffer_usage') + .desc( + ` +Tests indirect buffer must have 'Indirect' usage. + ` + ) + .paramsSubcasesOnly( + kIndirectDrawTestParams.combine('usage', [ + GPUConst.BufferUsage.INDIRECT, // control case + GPUConst.BufferUsage.COPY_DST, + GPUConst.BufferUsage.COPY_DST | GPUConst.BufferUsage.INDIRECT, + ] as const) + ) + .fn(t => { + const { encoderType, indexed, usage } = t.params; + const indirectBuffer = t.device.createBuffer({ + size: 256, + usage, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setPipeline(t.createNoOpRenderPipeline()); + if (indexed) { + const indexBuffer = t.makeIndexBuffer(); + encoder.setIndexBuffer(indexBuffer, 'uint32'); + encoder.drawIndexedIndirect(indirectBuffer, 0); + } else { + encoder.drawIndirect(indirectBuffer, 0); + } + validateFinish((usage & GPUBufferUsage.INDIRECT) !== 0); + }); + +g.test('indirect_offset_alignment') + .desc( + ` +Tests indirect offset must be a multiple of 4. + ` + ) + .paramsSubcasesOnly(kIndirectDrawTestParams.combine('indirectOffset', [0, 2, 4] as const)) + .fn(t => { + const { encoderType, indexed, indirectOffset } = t.params; + const pipeline = t.createNoOpRenderPipeline(); + const indirectBuffer = t.device.createBuffer({ + size: 256, + usage: GPUBufferUsage.INDIRECT, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setPipeline(pipeline); + if (indexed) { + const indexBuffer = t.makeIndexBuffer(); + encoder.setIndexBuffer(indexBuffer, 'uint32'); + encoder.drawIndexedIndirect(indirectBuffer, indirectOffset); + } else { + encoder.drawIndirect(indirectBuffer, indirectOffset); + } + + validateFinish(indirectOffset % 4 === 0); + }); + +g.test('indirect_offset_oob') + .desc( + ` +Tests indirect draw calls with various indirect offsets and buffer sizes. +- (offset, b.size) is + - (0, 0) + - (0, min size) (control case) + - (0, min size + 1) (control case) + - (0, min size - 1) + - (0, min size - min alignment) + - (min alignment, min size + min alignment) + - (min alignment, min size + min alignment - 1) + - (min alignment / 2, min size + min alignment) + - (min alignment +/- 1, min size + min alignment) + - (min size, min size) + - (min size + min alignment, min size) + - min size = indirect draw parameters size + - x =(drawIndirect, drawIndexedIndirect) + ` + ) + .paramsSubcasesOnly( + kIndirectDrawTestParams.expandWithParams(p => { + const indirectParamsSize = p.indexed ? 20 : 16; + return [ + { indirectOffset: 0, bufferSize: 0, _valid: false }, + { indirectOffset: 0, bufferSize: indirectParamsSize, _valid: true }, + { indirectOffset: 0, bufferSize: indirectParamsSize + 1, _valid: true }, + { indirectOffset: 0, bufferSize: indirectParamsSize - 1, _valid: false }, + { indirectOffset: 0, bufferSize: indirectParamsSize - 4, _valid: false }, + { indirectOffset: 4, bufferSize: indirectParamsSize + 4, _valid: true }, + { indirectOffset: 4, bufferSize: indirectParamsSize + 3, _valid: false }, + { indirectOffset: 2, bufferSize: indirectParamsSize + 4, _valid: false }, + { indirectOffset: 3, bufferSize: indirectParamsSize + 4, _valid: false }, + { indirectOffset: 5, bufferSize: indirectParamsSize + 4, _valid: false }, + { indirectOffset: indirectParamsSize, bufferSize: indirectParamsSize, _valid: false }, + { indirectOffset: indirectParamsSize + 4, bufferSize: indirectParamsSize, _valid: false }, + ] as const; + }) + ) + .fn(t => { + const { encoderType, indexed, indirectOffset, bufferSize, _valid } = t.params; + const pipeline = t.createNoOpRenderPipeline(); + const indirectBuffer = t.device.createBuffer({ + size: bufferSize, + usage: GPUBufferUsage.INDIRECT, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setPipeline(pipeline); + if (indexed) { + const indexBuffer = t.makeIndexBuffer(); + encoder.setIndexBuffer(indexBuffer, 'uint32'); + encoder.drawIndexedIndirect(indirectBuffer, indirectOffset); + } else { + encoder.drawIndirect(indirectBuffer, indirectOffset); + } + + validateFinish(_valid); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/render.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/render.ts new file mode 100644 index 0000000000..0df9ec6365 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/render.ts @@ -0,0 +1,29 @@ +import { kUnitCaseParamsBuilder } from '../../../../../../common/framework/params_builder.js'; +import { kRenderEncodeTypes } from '../../../../../util/command_buffer_maker.js'; + +export const kRenderEncodeTypeParams = kUnitCaseParamsBuilder.combine( + 'encoderType', + kRenderEncodeTypes +); + +export function buildBufferOffsetAndSizeOOBTestParams(minAlignment: number, bufferSize: number) { + return kRenderEncodeTypeParams.combineWithParams([ + // Explicit size + { offset: 0, size: 0, _valid: true }, + { offset: 0, size: 1, _valid: true }, + { offset: 0, size: 4, _valid: true }, + { offset: 0, size: 5, _valid: true }, + { offset: 0, size: bufferSize, _valid: true }, + { offset: 0, size: bufferSize + 4, _valid: false }, + { offset: minAlignment, size: bufferSize, _valid: false }, + { offset: minAlignment, size: bufferSize - minAlignment, _valid: true }, + { offset: bufferSize - minAlignment, size: minAlignment, _valid: true }, + { offset: bufferSize, size: 1, _valid: false }, + // Implicit size: buffer.size - offset + { offset: 0, size: undefined, _valid: true }, + { offset: minAlignment, size: undefined, _valid: true }, + { offset: bufferSize - minAlignment, size: undefined, _valid: true }, + { offset: bufferSize, size: undefined, _valid: true }, + { offset: bufferSize + minAlignment, size: undefined, _valid: false }, + ]); +} diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setIndexBuffer.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setIndexBuffer.spec.ts new file mode 100644 index 0000000000..1aacd8de90 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setIndexBuffer.spec.ts @@ -0,0 +1,124 @@ +export const description = ` +Validation tests for setIndexBuffer on render pass and render bundle. +`; + +import { makeTestGroup } from '../../../../../../common/framework/test_group.js'; +import { GPUConst } from '../../../../../constants.js'; +import { kResourceStates } from '../../../../../gpu_test.js'; +import { ValidationTest } from '../../../validation_test.js'; + +import { kRenderEncodeTypeParams, buildBufferOffsetAndSizeOOBTestParams } from './render.js'; + +export const g = makeTestGroup(ValidationTest); + +g.test('index_buffer_state') + .desc( + ` +Tests index buffer must be valid. + ` + ) + .paramsSubcasesOnly(kRenderEncodeTypeParams.combine('state', kResourceStates)) + .fn(t => { + const { encoderType, state } = t.params; + const indexBuffer = t.createBufferWithState(state, { + size: 16, + usage: GPUBufferUsage.INDEX, + }); + + const { encoder, validateFinishAndSubmitGivenState } = t.createEncoder(encoderType); + encoder.setIndexBuffer(indexBuffer, 'uint32'); + validateFinishAndSubmitGivenState(state); + }); + +g.test('index_buffer,device_mismatch') + .desc('Tests setIndexBuffer cannot be called with an index buffer created from another device') + .paramsSubcasesOnly(kRenderEncodeTypeParams.combine('mismatched', [true, false])) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { encoderType, mismatched } = t.params; + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + + const indexBuffer = sourceDevice.createBuffer({ + size: 16, + usage: GPUBufferUsage.INDEX, + }); + t.trackForCleanup(indexBuffer); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setIndexBuffer(indexBuffer, 'uint32'); + validateFinish(!mismatched); + }); + +g.test('index_buffer_usage') + .desc( + ` +Tests index buffer must have 'Index' usage. + ` + ) + .paramsSubcasesOnly( + kRenderEncodeTypeParams.combine('usage', [ + GPUConst.BufferUsage.INDEX, // control case + GPUConst.BufferUsage.COPY_DST, + GPUConst.BufferUsage.COPY_DST | GPUConst.BufferUsage.INDEX, + ] as const) + ) + .fn(t => { + const { encoderType, usage } = t.params; + const indexBuffer = t.device.createBuffer({ + size: 16, + usage, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setIndexBuffer(indexBuffer, 'uint32'); + validateFinish((usage & GPUBufferUsage.INDEX) !== 0); + }); + +g.test('offset_alignment') + .desc( + ` +Tests offset must be a multiple of index format’s byte size. + ` + ) + .paramsSubcasesOnly( + kRenderEncodeTypeParams + .combine('indexFormat', ['uint16', 'uint32'] as const) + .expand('offset', p => { + return p.indexFormat === 'uint16' ? ([0, 1, 2] as const) : ([0, 2, 4] as const); + }) + ) + .fn(t => { + const { encoderType, indexFormat, offset } = t.params; + const indexBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.INDEX, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setIndexBuffer(indexBuffer, indexFormat, offset); + + const alignment = + indexFormat === 'uint16' ? Uint16Array.BYTES_PER_ELEMENT : Uint32Array.BYTES_PER_ELEMENT; + validateFinish(offset % alignment === 0); + }); + +g.test('offset_and_size_oob') + .desc( + ` +Tests offset and size cannot be larger than index buffer size. + ` + ) + .paramsSubcasesOnly(buildBufferOffsetAndSizeOOBTestParams(4, 256)) + .fn(t => { + const { encoderType, offset, size, _valid } = t.params; + const indexBuffer = t.device.createBuffer({ + size: 256, + usage: GPUBufferUsage.INDEX, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setIndexBuffer(indexBuffer, 'uint32', offset, size); + validateFinish(_valid); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setPipeline.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setPipeline.spec.ts new file mode 100644 index 0000000000..6fcd8015d3 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setPipeline.spec.ts @@ -0,0 +1,62 @@ +export const description = ` +Validation tests for setPipeline on render pass and render bundle. +`; + +import { makeTestGroup } from '../../../../../../common/framework/test_group.js'; +import { kRenderEncodeTypes } from '../../../../../util/command_buffer_maker.js'; +import { ValidationTest } from '../../../validation_test.js'; + +import { kRenderEncodeTypeParams } from './render.js'; + +export const g = makeTestGroup(ValidationTest); + +g.test('invalid_pipeline') + .desc( + ` +Tests setPipeline should generate an error iff using an 'invalid' pipeline. + ` + ) + .paramsSubcasesOnly(u => + u.combine('encoderType', kRenderEncodeTypes).combine('state', ['valid', 'invalid'] as const) + ) + .fn(t => { + const { encoderType, state } = t.params; + const pipeline = t.createRenderPipelineWithState(state); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setPipeline(pipeline); + validateFinish(state !== 'invalid'); + }); + +g.test('pipeline,device_mismatch') + .desc('Tests setPipeline cannot be called with a render pipeline created from another device') + .paramsSubcasesOnly(kRenderEncodeTypeParams.combine('mismatched', [true, false])) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { encoderType, mismatched } = t.params; + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + + const pipeline = sourceDevice.createRenderPipeline({ + layout: 'auto', + vertex: { + module: sourceDevice.createShaderModule({ + code: `@vertex fn main() -> @builtin(position) vec4<f32> { return vec4<f32>(); }`, + }), + entryPoint: 'main', + }, + fragment: { + module: sourceDevice.createShaderModule({ + code: '@fragment fn main() {}', + }), + entryPoint: 'main', + targets: [{ format: 'rgba8unorm', writeMask: 0 }], + }, + primitive: { topology: 'triangle-list' }, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setPipeline(pipeline); + validateFinish(!mismatched); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setVertexBuffer.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setVertexBuffer.spec.ts new file mode 100644 index 0000000000..453281dbdd --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/setVertexBuffer.spec.ts @@ -0,0 +1,141 @@ +export const description = ` +Validation tests for setVertexBuffer on render pass and render bundle. +`; + +import { makeTestGroup } from '../../../../../../common/framework/test_group.js'; +import { kLimitInfo } from '../../../../../capability_info.js'; +import { GPUConst } from '../../../../../constants.js'; +import { kResourceStates } from '../../../../../gpu_test.js'; +import { ValidationTest } from '../../../validation_test.js'; + +import { kRenderEncodeTypeParams, buildBufferOffsetAndSizeOOBTestParams } from './render.js'; + +export const g = makeTestGroup(ValidationTest); + +g.test('slot') + .desc( + ` +Tests slot must be less than the maxVertexBuffers in device limits. + ` + ) + .paramsSubcasesOnly( + kRenderEncodeTypeParams.combine('slot', [ + 0, + kLimitInfo.maxVertexBuffers.default - 1, + kLimitInfo.maxVertexBuffers.default, + ] as const) + ) + .fn(t => { + const { encoderType, slot } = t.params; + const vertexBuffer = t.createBufferWithState('valid', { + size: 16, + usage: GPUBufferUsage.VERTEX, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setVertexBuffer(slot, vertexBuffer); + validateFinish(slot < kLimitInfo.maxVertexBuffers.default); + }); + +g.test('vertex_buffer_state') + .desc( + ` +Tests vertex buffer must be valid. + ` + ) + .paramsSubcasesOnly(kRenderEncodeTypeParams.combine('state', kResourceStates)) + .fn(t => { + const { encoderType, state } = t.params; + const vertexBuffer = t.createBufferWithState(state, { + size: 16, + usage: GPUBufferUsage.VERTEX, + }); + + const { encoder, validateFinishAndSubmitGivenState } = t.createEncoder(encoderType); + encoder.setVertexBuffer(0, vertexBuffer); + validateFinishAndSubmitGivenState(state); + }); + +g.test('vertex_buffer,device_mismatch') + .desc('Tests setVertexBuffer cannot be called with a vertex buffer created from another device') + .paramsSubcasesOnly(kRenderEncodeTypeParams.combine('mismatched', [true, false])) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { encoderType, mismatched } = t.params; + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + + const vertexBuffer = sourceDevice.createBuffer({ + size: 16, + usage: GPUBufferUsage.VERTEX, + }); + t.trackForCleanup(vertexBuffer); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setVertexBuffer(0, vertexBuffer); + validateFinish(!mismatched); + }); + +g.test('vertex_buffer_usage') + .desc( + ` +Tests vertex buffer must have 'Vertex' usage. + ` + ) + .paramsSubcasesOnly( + kRenderEncodeTypeParams.combine('usage', [ + GPUConst.BufferUsage.VERTEX, // control case + GPUConst.BufferUsage.COPY_DST, + GPUConst.BufferUsage.COPY_DST | GPUConst.BufferUsage.VERTEX, + ] as const) + ) + .fn(t => { + const { encoderType, usage } = t.params; + const vertexBuffer = t.device.createBuffer({ + size: 16, + usage, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setVertexBuffer(0, vertexBuffer); + validateFinish((usage & GPUBufferUsage.VERTEX) !== 0); + }); + +g.test('offset_alignment') + .desc( + ` +Tests offset must be a multiple of 4. + ` + ) + .paramsSubcasesOnly(kRenderEncodeTypeParams.combine('offset', [0, 2, 4] as const)) + .fn(t => { + const { encoderType, offset } = t.params; + const vertexBuffer = t.device.createBuffer({ + size: 16, + usage: GPUBufferUsage.VERTEX, + }); + + const { encoder, validateFinish: finish } = t.createEncoder(encoderType); + encoder.setVertexBuffer(0, vertexBuffer, offset); + finish(offset % 4 === 0); + }); + +g.test('offset_and_size_oob') + .desc( + ` +Tests offset and size cannot be larger than vertex buffer size. + ` + ) + .paramsSubcasesOnly(buildBufferOffsetAndSizeOOBTestParams(4, 256)) + .fn(t => { + const { encoderType, offset, size, _valid } = t.params; + const vertexBuffer = t.device.createBuffer({ + size: 256, + usage: GPUBufferUsage.VERTEX, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setVertexBuffer(0, vertexBuffer, offset, size); + validateFinish(_valid); + }); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/state_tracking.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/state_tracking.spec.ts new file mode 100644 index 0000000000..310f96a9df --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render/state_tracking.spec.ts @@ -0,0 +1,184 @@ +export const description = ` +Validation tests for setVertexBuffer/setIndexBuffer state (not validation). See also operation tests. +`; + +import { makeTestGroup } from '../../../../../../common/framework/test_group.js'; +import { range } from '../../../../../../common/util/util.js'; +import { ValidationTest } from '../../../validation_test.js'; + +class F extends ValidationTest { + getVertexBuffer(): GPUBuffer { + return this.device.createBuffer({ + size: 256, + usage: GPUBufferUsage.VERTEX, + }); + } + + createRenderPipeline(bufferCount: number): GPURenderPipeline { + return this.device.createRenderPipeline({ + layout: 'auto', + vertex: { + module: this.device.createShaderModule({ + code: ` + struct Inputs { + ${range(bufferCount, i => `\n@location(${i}) a_position${i} : vec3<f32>,`).join('')} + }; + @vertex fn main(input : Inputs + ) -> @builtin(position) vec4<f32> { + return vec4<f32>(0.0, 0.0, 0.0, 1.0); + }`, + }), + entryPoint: 'main', + buffers: [ + { + arrayStride: 3 * 4, + attributes: range(bufferCount, i => ({ + format: 'float32x3', + offset: 0, + shaderLocation: i, + })), + }, + ], + }, + fragment: { + module: this.device.createShaderModule({ + code: ` + @fragment fn main() -> @location(0) vec4<f32> { + return vec4<f32>(0.0, 1.0, 0.0, 1.0); + }`, + }), + entryPoint: 'main', + targets: [{ format: 'rgba8unorm' }], + }, + primitive: { topology: 'triangle-list' }, + }); + } + + beginRenderPass(commandEncoder: GPUCommandEncoder): GPURenderPassEncoder { + const attachmentTexture = this.device.createTexture({ + format: 'rgba8unorm', + size: { width: 16, height: 16, depthOrArrayLayers: 1 }, + usage: GPUTextureUsage.RENDER_ATTACHMENT, + }); + + return commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: attachmentTexture.createView(), + clearValue: { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }, + loadOp: 'clear', + storeOp: 'store', + }, + ], + }); + } +} + +export const g = makeTestGroup(F); + +g.test(`all_needed_vertex_buffer_should_be_bound`) + .desc( + ` +In this test we test that any missing vertex buffer for a used slot will cause validation errors when drawing. +- All (non/indexed, in/direct) draw commands + - A needed vertex buffer is not bound + - Was bound in another render pass but not the current one +` + ) + .unimplemented(); + +g.test(`all_needed_index_buffer_should_be_bound`) + .desc( + ` +In this test we test that missing index buffer for a used slot will cause validation errors when drawing. +- All indexed in/direct draw commands + - No index buffer is bound +` + ) + .unimplemented(); + +g.test('vertex_buffers_inherit_from_previous_pipeline').fn(async t => { + const pipeline1 = t.createRenderPipeline(1); + const pipeline2 = t.createRenderPipeline(2); + + const vertexBuffer1 = t.getVertexBuffer(); + const vertexBuffer2 = t.getVertexBuffer(); + + { + // Check failure when vertex buffer is not set + const commandEncoder = t.device.createCommandEncoder(); + const renderPass = t.beginRenderPass(commandEncoder); + renderPass.setPipeline(pipeline1); + renderPass.draw(3); + renderPass.end(); + + t.expectValidationError(() => { + commandEncoder.finish(); + }); + } + { + // Check success when vertex buffer is inherited from previous pipeline + const commandEncoder = t.device.createCommandEncoder(); + const renderPass = t.beginRenderPass(commandEncoder); + renderPass.setPipeline(pipeline2); + renderPass.setVertexBuffer(0, vertexBuffer1); + renderPass.setVertexBuffer(1, vertexBuffer2); + renderPass.draw(3); + renderPass.setPipeline(pipeline1); + renderPass.draw(3); + renderPass.end(); + + commandEncoder.finish(); + } +}); + +g.test('vertex_buffers_do_not_inherit_between_render_passes').fn(async t => { + const pipeline1 = t.createRenderPipeline(1); + const pipeline2 = t.createRenderPipeline(2); + + const vertexBuffer1 = t.getVertexBuffer(); + const vertexBuffer2 = t.getVertexBuffer(); + + { + // Check success when vertex buffer is set for each render pass + const commandEncoder = t.device.createCommandEncoder(); + { + const renderPass = t.beginRenderPass(commandEncoder); + renderPass.setPipeline(pipeline2); + renderPass.setVertexBuffer(0, vertexBuffer1); + renderPass.setVertexBuffer(1, vertexBuffer2); + renderPass.draw(3); + renderPass.end(); + } + { + const renderPass = t.beginRenderPass(commandEncoder); + renderPass.setPipeline(pipeline1); + renderPass.setVertexBuffer(0, vertexBuffer1); + renderPass.draw(3); + renderPass.end(); + } + commandEncoder.finish(); + } + { + // Check failure because vertex buffer is not inherited in second subpass + const commandEncoder = t.device.createCommandEncoder(); + { + const renderPass = t.beginRenderPass(commandEncoder); + renderPass.setPipeline(pipeline2); + renderPass.setVertexBuffer(0, vertexBuffer1); + renderPass.setVertexBuffer(1, vertexBuffer2); + renderPass.draw(3); + renderPass.end(); + } + { + const renderPass = t.beginRenderPass(commandEncoder); + renderPass.setPipeline(pipeline1); + renderPass.draw(3); + renderPass.end(); + } + + t.expectValidationError(() => { + commandEncoder.finish(); + }); + } +}); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render_pass.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render_pass.spec.ts new file mode 100644 index 0000000000..e3e881e01d --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/render_pass.spec.ts @@ -0,0 +1,14 @@ +export const description = ` +Validation tests for render pass encoding. +Does **not** test usage scopes (resource_usages/), GPUProgrammablePassEncoder (programmable_pass), +dynamic state (dynamic_render_state.spec.ts), or GPURenderEncoderBase (render.spec.ts). + +TODO: +- executeBundles: + - with {zero, one, multiple} bundles where {zero, one} of them are invalid objects +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { ValidationTest } from '../../validation_test.js'; + +export const g = makeTestGroup(ValidationTest); diff --git a/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/setBindGroup.spec.ts b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/setBindGroup.spec.ts new file mode 100644 index 0000000000..476ad576e1 --- /dev/null +++ b/dom/webgpu/tests/cts/checkout/src/webgpu/api/validation/encoding/cmds/setBindGroup.spec.ts @@ -0,0 +1,446 @@ +export const description = ` +setBindGroup validation tests. + +TODO: merge these notes and implement. +> (Note: If there are errors with using certain binding types in certain passes, test those in the file for that pass type, not here.) +> +> - state tracking (probably separate file) +> - x= {compute pass, render pass} +> - {null, compatible, incompatible} current pipeline (should have no effect without draw/dispatch) +> - setBindGroup in different orders (e.g. 0,1,2 vs 2,0,1) +`; + +import { makeTestGroup } from '../../../../../common/framework/test_group.js'; +import { range, unreachable } from '../../../../../common/util/util.js'; +import { + kBufferBindingTypes, + kMinDynamicBufferOffsetAlignment, + kLimitInfo, +} from '../../../../capability_info.js'; +import { kResourceStates, ResourceState } from '../../../../gpu_test.js'; +import { + kProgrammableEncoderTypes, + ProgrammableEncoderType, +} from '../../../../util/command_buffer_maker.js'; +import { ValidationTest } from '../../validation_test.js'; + +class F extends ValidationTest { + encoderTypeToStageFlag(encoderType: ProgrammableEncoderType): GPUShaderStageFlags { + switch (encoderType) { + case 'compute pass': + return GPUShaderStage.COMPUTE; + case 'render pass': + case 'render bundle': + return GPUShaderStage.FRAGMENT; + default: + unreachable('Unknown encoder type'); + } + } + + createBindingResourceWithState( + resourceType: 'texture' | 'buffer', + state: 'valid' | 'destroyed' + ): GPUBindingResource { + switch (resourceType) { + case 'texture': { + const texture = this.createTextureWithState('valid'); + const view = texture.createView(); + if (state === 'destroyed') { + texture.destroy(); + } + return view; + } + case 'buffer': + return { + buffer: this.createBufferWithState(state, { + size: 4, + usage: GPUBufferUsage.STORAGE, + }), + }; + default: + unreachable('unknown resource type'); + } + } + + /** + * If state is 'invalid', creates an invalid bind group with valid resources. + * If state is 'destroyed', creates a valid bind group with destroyed resources. + */ + createBindGroup( + state: ResourceState, + resourceType: 'buffer' | 'texture', + encoderType: ProgrammableEncoderType, + indices: number[] + ) { + if (state === 'invalid') { + this.device.pushErrorScope('validation'); + indices = new Array<number>(indices.length + 1).fill(0); + } + + const layout = this.device.createBindGroupLayout({ + entries: indices.map(binding => ({ + binding, + visibility: this.encoderTypeToStageFlag(encoderType), + ...(resourceType === 'buffer' ? { buffer: { type: 'storage' } } : { texture: {} }), + })), + }); + const bindGroup = this.device.createBindGroup({ + layout, + entries: indices.map(binding => ({ + binding, + resource: this.createBindingResourceWithState( + resourceType, + state === 'destroyed' ? state : 'valid' + ), + })), + }); + + if (state === 'invalid') { + void this.device.popErrorScope(); + } + return bindGroup; + } +} + +export const g = makeTestGroup(F); + +g.test('state_and_binding_index') + .desc('Tests that setBindGroup correctly handles {valid, invalid, destroyed} bindGroups.') + .params(u => + u + .combine('encoderType', kProgrammableEncoderTypes) + .combine('state', kResourceStates) + .combine('resourceType', ['buffer', 'texture'] as const) + ) + .fn(async t => { + const { encoderType, state, resourceType } = t.params; + const maxBindGroups = t.device.limits.maxBindGroups; + + async function runTest(index: number) { + const { encoder, validateFinishAndSubmit } = t.createEncoder(encoderType); + encoder.setBindGroup(index, t.createBindGroup(state, resourceType, encoderType, [index])); + + validateFinishAndSubmit(state !== 'invalid' && index < maxBindGroups, state !== 'destroyed'); + } + + // MAINTENANCE_TODO: move to subcases() once we can query the device limits + for (const index of [1, maxBindGroups - 1, maxBindGroups]) { + t.debug(`test bind group index ${index}`); + await runTest(index); + } + }); + +g.test('bind_group,device_mismatch') + .desc( + ` + Tests setBindGroup cannot be called with a bind group created from another device + - x= setBindGroup {sequence overload, Uint32Array overload} + ` + ) + .params(u => + u + .combine('encoderType', kProgrammableEncoderTypes) + .beginSubcases() + .combine('useU32Array', [true, false]) + .combine('mismatched', [true, false]) + ) + .beforeAllSubcases(t => { + t.selectMismatchedDeviceOrSkipTestCase(undefined); + }) + .fn(async t => { + const { encoderType, useU32Array, mismatched } = t.params; + const sourceDevice = mismatched ? t.mismatchedDevice : t.device; + + const buffer = sourceDevice.createBuffer({ + size: 4, + usage: GPUBufferUsage.STORAGE, + }); + + const layout = sourceDevice.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: t.encoderTypeToStageFlag(encoderType), + buffer: { type: 'storage', hasDynamicOffset: useU32Array }, + }, + ], + }); + + const bindGroup = sourceDevice.createBindGroup({ + layout, + entries: [ + { + binding: 0, + resource: { buffer }, + }, + ], + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + if (useU32Array) { + encoder.setBindGroup(0, bindGroup, new Uint32Array([0]), 0, 1); + } else { + encoder.setBindGroup(0, bindGroup); + } + validateFinish(!mismatched); + }); + +g.test('dynamic_offsets_passed_but_not_expected') + .desc('Tests that setBindGroup correctly errors on unexpected dynamicOffsets.') + .params(u => u.combine('encoderType', kProgrammableEncoderTypes)) + .fn(async t => { + const { encoderType } = t.params; + const bindGroup = t.createBindGroup('valid', 'buffer', encoderType, []); + const dynamicOffsets = [0]; + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setBindGroup(0, bindGroup, dynamicOffsets); + validateFinish(false); + }); + +g.test('dynamic_offsets_match_expectations_in_pass_encoder') + .desc('Tests that given dynamicOffsets match the specified bindGroup.') + .params(u => + u + .combine('encoderType', kProgrammableEncoderTypes) + .combineWithParams([ + { dynamicOffsets: [256, 0], _success: true }, // Dynamic offsets aligned + { dynamicOffsets: [1, 2], _success: false }, // Dynamic offsets not aligned + + // Wrong number of dynamic offsets + { dynamicOffsets: [256, 0, 0], _success: false }, + { dynamicOffsets: [256], _success: false }, + { dynamicOffsets: [], _success: false }, + + // Dynamic uniform buffer out of bounds because of binding size + { dynamicOffsets: [512, 0], _success: false }, + { dynamicOffsets: [1024, 0], _success: false }, + { dynamicOffsets: [0xffffffff, 0], _success: false }, + + // Dynamic storage buffer out of bounds because of binding size + { dynamicOffsets: [0, 512], _success: false }, + { dynamicOffsets: [0, 1024], _success: false }, + { dynamicOffsets: [0, 0xffffffff], _success: false }, + ]) + .combine('useU32array', [false, true]) + ) + .fn(async t => { + const kBindingSize = 12; + + const bindGroupLayout = t.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.COMPUTE | GPUShaderStage.FRAGMENT, + buffer: { + type: 'uniform', + hasDynamicOffset: true, + }, + }, + { + binding: 1, + visibility: GPUShaderStage.COMPUTE | GPUShaderStage.FRAGMENT, + buffer: { + type: 'storage', + hasDynamicOffset: true, + }, + }, + ], + }); + + const uniformBuffer = t.device.createBuffer({ + size: 2 * kMinDynamicBufferOffsetAlignment + 8, + usage: GPUBufferUsage.UNIFORM, + }); + + const storageBuffer = t.device.createBuffer({ + size: 2 * kMinDynamicBufferOffsetAlignment + 8, + usage: GPUBufferUsage.STORAGE, + }); + + const bindGroup = t.device.createBindGroup({ + layout: bindGroupLayout, + entries: [ + { + binding: 0, + resource: { + buffer: uniformBuffer, + size: kBindingSize, + }, + }, + { + binding: 1, + resource: { + buffer: storageBuffer, + size: kBindingSize, + }, + }, + ], + }); + + const { encoderType, dynamicOffsets, useU32array, _success } = t.params; + + const { encoder, validateFinish } = t.createEncoder(encoderType); + if (useU32array) { + encoder.setBindGroup(0, bindGroup, new Uint32Array(dynamicOffsets), 0, dynamicOffsets.length); + } else { + encoder.setBindGroup(0, bindGroup, dynamicOffsets); + } + validateFinish(_success); + }); + +g.test('u32array_start_and_length') + .desc('Tests that dynamicOffsetsData(Start|Length) apply to the given Uint32Array.') + .paramsSubcasesOnly([ + // dynamicOffsetsDataLength > offsets.length + { + offsets: [0] as const, + dynamicOffsetsDataStart: 0, + dynamicOffsetsDataLength: 2, + _success: false, + }, + // dynamicOffsetsDataStart + dynamicOffsetsDataLength > offsets.length + { + offsets: [0] as const, + dynamicOffsetsDataStart: 1, + dynamicOffsetsDataLength: 1, + _success: false, + }, + { + offsets: [0, 0] as const, + dynamicOffsetsDataStart: 1, + dynamicOffsetsDataLength: 1, + _success: true, + }, + { + offsets: [0, 0, 0] as const, + dynamicOffsetsDataStart: 1, + dynamicOffsetsDataLength: 1, + _success: true, + }, + { + offsets: [0, 0] as const, + dynamicOffsetsDataStart: 0, + dynamicOffsetsDataLength: 2, + _success: true, + }, + ]) + .fn(t => { + const { offsets, dynamicOffsetsDataStart, dynamicOffsetsDataLength, _success } = t.params; + const kBindingSize = 8; + + const bindGroupLayout = t.device.createBindGroupLayout({ + entries: range(dynamicOffsetsDataLength, i => ({ + binding: i, + visibility: GPUShaderStage.FRAGMENT, + buffer: { + type: 'storage', + hasDynamicOffset: true, + }, + })), + }); + + const bindGroup = t.device.createBindGroup({ + layout: bindGroupLayout, + entries: range(dynamicOffsetsDataLength, i => ({ + binding: i, + resource: { + buffer: t.createBufferWithState('valid', { + size: kBindingSize, + usage: GPUBufferUsage.STORAGE, + }), + size: kBindingSize, + }, + })), + }); + + const { encoder, validateFinish } = t.createEncoder('render pass'); + + const doSetBindGroup = () => { + encoder.setBindGroup( + 0, + bindGroup, + new Uint32Array(offsets), + dynamicOffsetsDataStart, + dynamicOffsetsDataLength + ); + }; + + if (_success) { + doSetBindGroup(); + } else { + t.shouldThrow('RangeError', doSetBindGroup); + } + + // RangeError in setBindGroup does not cause the encoder to become invalid. + validateFinish(true); + }); + +g.test('buffer_dynamic_offsets') + .desc( + ` + Test that the dynamic offsets of the BufferLayout is a multiple of + 'minUniformBufferOffsetAlignment|minStorageBufferOffsetAlignment' if the BindGroup entry defines + buffer and the buffer type is 'uniform|storage|read-only-storage'. + ` + ) + .params(u => + u // + .combine('type', kBufferBindingTypes) + .combine('encoderType', kProgrammableEncoderTypes) + .beginSubcases() + .expand('dynamicOffset', ({ type }) => + type === 'uniform' + ? [ + kLimitInfo.minUniformBufferOffsetAlignment.default, + kLimitInfo.minUniformBufferOffsetAlignment.default * 0.5, + kLimitInfo.minUniformBufferOffsetAlignment.default * 1.5, + kLimitInfo.minUniformBufferOffsetAlignment.default * 2, + kLimitInfo.minUniformBufferOffsetAlignment.default + 2, + ] + : [ + kLimitInfo.minStorageBufferOffsetAlignment.default, + kLimitInfo.minStorageBufferOffsetAlignment.default * 0.5, + kLimitInfo.minStorageBufferOffsetAlignment.default * 1.5, + kLimitInfo.minStorageBufferOffsetAlignment.default * 2, + kLimitInfo.minStorageBufferOffsetAlignment.default + 2, + ] + ) + ) + .fn(async t => { + const { type, dynamicOffset, encoderType } = t.params; + const kBindingSize = 12; + + const bindGroupLayout = t.device.createBindGroupLayout({ + entries: [ + { + binding: 0, + visibility: GPUShaderStage.COMPUTE, + buffer: { type, hasDynamicOffset: true }, + }, + ], + }); + + let usage, isValid; + if (type === 'uniform') { + usage = GPUBufferUsage.UNIFORM; + isValid = dynamicOffset % kLimitInfo.minUniformBufferOffsetAlignment.default === 0; + } else { + usage = GPUBufferUsage.STORAGE; + isValid = dynamicOffset % kLimitInfo.minStorageBufferOffsetAlignment.default === 0; + } + + const buffer = t.device.createBuffer({ + size: 3 * kMinDynamicBufferOffsetAlignment, + usage, + }); + + const bindGroup = t.device.createBindGroup({ + entries: [{ binding: 0, resource: { buffer, size: kBindingSize } }], + layout: bindGroupLayout, + }); + + const { encoder, validateFinish } = t.createEncoder(encoderType); + encoder.setBindGroup(0, bindGroup, [dynamicOffset]); + validateFinish(isValid); + }); |