blob: 882c5f75f2b5d32830a72004467364edbea05ecc [file] [log] [blame]
import {
Fixture,
FixtureClass,
FixtureClassInterface,
FixtureClassWithMixin,
SubcaseBatchState,
TestParams,
} from '../common/framework/fixture.js';
import {
assert,
range,
TypedArrayBufferView,
TypedArrayBufferViewConstructor,
unreachable,
} from '../common/util/util.js';
import {
EncodableTextureFormat,
SizedTextureFormat,
kTextureFormatInfo,
kQueryTypeInfo,
resolvePerAspectFormat,
kEncodableTextureFormats,
} from './capability_info.js';
import { makeBufferWithContents } from './util/buffer.js';
import { checkElementsEqual, checkElementsBetween } from './util/check_contents.js';
import { CommandBufferMaker, EncoderType } from './util/command_buffer_maker.js';
import { ScalarType } from './util/conversion.js';
import { DevicePool, DeviceProvider, UncanonicalizedDeviceDescriptor } from './util/device_pool.js';
import { align, roundDown } from './util/math.js';
import { createTextureFromTexelView, createTextureFromTexelViews } from './util/texture.js';
import {
getTextureCopyLayout,
getTextureSubCopyLayout,
LayoutOptions as TextureLayoutOptions,
} from './util/texture/layout.js';
import { PerTexelComponent, kTexelRepresentationInfo } from './util/texture/texel_data.js';
import { TexelView } from './util/texture/texel_view.js';
import {
PerPixelComparison,
PixelExpectation,
TexelCompareOptions,
textureContentIsOKByT2B,
} from './util/texture/texture_ok.js';
import { reifyOrigin3D } from './util/unions.js';
const devicePool = new DevicePool();
// MAINTENANCE_TODO: When DevicePool becomes able to provide multiple devices at once, use the
// usual one instead of a new one.
const mismatchedDevicePool = new DevicePool();
const kResourceStateValues = ['valid', 'invalid', 'destroyed'] as const;
export type ResourceState = typeof kResourceStateValues[number];
export const kResourceStates: readonly ResourceState[] = kResourceStateValues;
/** Various "convenient" shorthands for GPUDeviceDescriptors for selectDevice functions. */
type DeviceSelectionDescriptor =
| UncanonicalizedDeviceDescriptor
| GPUFeatureName
| undefined
| Array<GPUFeatureName | undefined>;
export function initUncanonicalizedDeviceDescriptor(
descriptor: DeviceSelectionDescriptor
): UncanonicalizedDeviceDescriptor | undefined {
if (typeof descriptor === 'string') {
return { requiredFeatures: [descriptor] };
} else if (descriptor instanceof Array) {
return {
requiredFeatures: descriptor.filter(f => f !== undefined) as GPUFeatureName[],
};
} else {
return descriptor;
}
}
export class GPUTestSubcaseBatchState extends SubcaseBatchState {
/** Provider for default device. */
private provider: Promise<DeviceProvider> | undefined;
/** Provider for mismatched device. */
private mismatchedProvider: Promise<DeviceProvider> | undefined;
async postInit(): Promise<void> {
// Skip all subcases if there's no device.
await this.acquireProvider();
}
async finalize(): Promise<void> {
await super.finalize();
// Ensure devicePool.release is called for both providers even if one rejects.
await Promise.all([
this.provider?.then(x => devicePool.release(x)),
this.mismatchedProvider?.then(x => devicePool.release(x)),
]);
}
/** @internal MAINTENANCE_TODO: Make this not visible to test code? */
acquireProvider(): Promise<DeviceProvider> {
if (this.provider === undefined) {
this.selectDeviceOrSkipTestCase(undefined);
}
assert(this.provider !== undefined);
return this.provider;
}
/**
* Some tests or cases need particular feature flags or limits to be enabled.
* Call this function with a descriptor or feature name (or `undefined`) to select a
* GPUDevice with matching capabilities. If this isn't called, a default device is provided.
*
* If the request isn't supported, throws a SkipTestCase exception to skip the entire test case.
*/
selectDeviceOrSkipTestCase(descriptor: DeviceSelectionDescriptor): void {
assert(this.provider === undefined, "Can't selectDeviceOrSkipTestCase() multiple times");
this.provider = devicePool.acquire(initUncanonicalizedDeviceDescriptor(descriptor));
// Suppress uncaught promise rejection (we'll catch it later).
this.provider.catch(() => {});
}
/**
* Convenience function for {@link selectDeviceOrSkipTestCase}.
* Select a device with the features required by these texture format(s).
* If the device creation fails, then skip the test case.
*/
selectDeviceForTextureFormatOrSkipTestCase(
formats: GPUTextureFormat | undefined | (GPUTextureFormat | undefined)[]
): void {
if (!Array.isArray(formats)) {
formats = [formats];
}
const features = new Set<GPUFeatureName | undefined>();
for (const format of formats) {
if (format !== undefined) {
features.add(kTextureFormatInfo[format].feature);
}
}
this.selectDeviceOrSkipTestCase(Array.from(features));
}
/**
* Convenience function for {@link selectDeviceOrSkipTestCase}.
* Select a device with the features required by these query type(s).
* If the device creation fails, then skip the test case.
*/
selectDeviceForQueryTypeOrSkipTestCase(types: GPUQueryType | GPUQueryType[]): void {
if (!Array.isArray(types)) {
types = [types];
}
const features = types.map(t => kQueryTypeInfo[t].feature);
this.selectDeviceOrSkipTestCase(features);
}
/** @internal MAINTENANCE_TODO: Make this not visible to test code? */
acquireMismatchedProvider(): Promise<DeviceProvider> | undefined {
return this.mismatchedProvider;
}
/**
* Some tests need a second device which is different from the first.
* This requests a second device so it will be available during the test. If it is not called,
* no second device will be available.
*
* If the request isn't supported, throws a SkipTestCase exception to skip the entire test case.
*/
selectMismatchedDeviceOrSkipTestCase(descriptor: DeviceSelectionDescriptor): void {
assert(
this.mismatchedProvider === undefined,
"Can't selectMismatchedDeviceOrSkipTestCase() multiple times"
);
this.mismatchedProvider = mismatchedDevicePool.acquire(
initUncanonicalizedDeviceDescriptor(descriptor)
);
// Suppress uncaught promise rejection (we'll catch it later).
this.mismatchedProvider.catch(() => {});
}
}
/**
* Base fixture for WebGPU tests.
*/
export class GPUTest extends Fixture<GPUTestSubcaseBatchState> {
public static MakeSharedState(params: TestParams): GPUTestSubcaseBatchState {
return new GPUTestSubcaseBatchState(params);
}
// Should never be undefined in a test. If it is, init() must not have run/finished.
private provider: DeviceProvider | undefined;
private mismatchedProvider: DeviceProvider | undefined;
async init() {
await super.init();
this.provider = await this.sharedState.acquireProvider();
this.mismatchedProvider = await this.sharedState.acquireMismatchedProvider();
}
/**
* GPUDevice for the test to use.
*/
get device(): GPUDevice {
assert(this.provider !== undefined, 'internal error: GPUDevice missing?');
return this.provider.device;
}
/**
* GPUDevice for tests requiring a second device different from the default one,
* e.g. for creating objects for by device_mismatch validation tests.
*/
get mismatchedDevice(): GPUDevice {
assert(
this.mismatchedProvider !== undefined,
'selectMismatchedDeviceOrSkipTestCase was not called in beforeAllSubcases'
);
return this.mismatchedProvider.device;
}
/** GPUQueue for the test to use. (Same as `t.device.queue`.) */
get queue(): GPUQueue {
return this.device.queue;
}
/** Snapshot a GPUBuffer's contents, returning a new GPUBuffer with the `MAP_READ` usage. */
private createCopyForMapRead(src: GPUBuffer, srcOffset: number, size: number): GPUBuffer {
assert(srcOffset % 4 === 0);
assert(size % 4 === 0);
const dst = this.device.createBuffer({
size,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
this.trackForCleanup(dst);
const c = this.device.createCommandEncoder();
c.copyBufferToBuffer(src, srcOffset, dst, 0, size);
this.queue.submit([c.finish()]);
return dst;
}
/**
* Offset and size passed to createCopyForMapRead must be divisible by 4. For that
* we might need to copy more bytes from the buffer than we want to map.
* begin and end values represent the part of the copied buffer that stores the contents
* we initially wanted to map.
* The copy will not cause an OOB error because the buffer size must be 4-aligned.
*/
private createAlignedCopyForMapRead(
src: GPUBuffer,
size: number,
offset: number
): { mappable: GPUBuffer; subarrayByteStart: number } {
const alignedOffset = roundDown(offset, 4);
const subarrayByteStart = offset - alignedOffset;
const alignedSize = align(size + subarrayByteStart, 4);
const mappable = this.createCopyForMapRead(src, alignedOffset, alignedSize);
return { mappable, subarrayByteStart };
}
/**
* Snapshot the current contents of a range of a GPUBuffer, and return them as a TypedArray.
* Also provides a cleanup() function to unmap and destroy the staging buffer.
*/
async readGPUBufferRangeTyped<T extends TypedArrayBufferView>(
src: GPUBuffer,
{
srcByteOffset = 0,
method = 'copy',
type,
typedLength,
}: {
srcByteOffset?: number;
method?: 'copy' | 'map';
type: TypedArrayBufferViewConstructor<T>;
typedLength: number;
}
): Promise<{ data: T; cleanup(): void }> {
assert(
srcByteOffset % type.BYTES_PER_ELEMENT === 0,
'srcByteOffset must be a multiple of BYTES_PER_ELEMENT'
);
const byteLength = typedLength * type.BYTES_PER_ELEMENT;
let mappable: GPUBuffer;
let mapOffset: number | undefined, mapSize: number | undefined, subarrayByteStart: number;
if (method === 'copy') {
({ mappable, subarrayByteStart } = this.createAlignedCopyForMapRead(
src,
byteLength,
srcByteOffset
));
} else if (method === 'map') {
mappable = src;
mapOffset = roundDown(srcByteOffset, 8);
mapSize = align(byteLength, 4);
subarrayByteStart = srcByteOffset - mapOffset;
} else {
unreachable();
}
assert(subarrayByteStart % type.BYTES_PER_ELEMENT === 0);
const subarrayStart = subarrayByteStart / type.BYTES_PER_ELEMENT;
// 2. Map the staging buffer, and create the TypedArray from it.
await mappable.mapAsync(GPUMapMode.READ, mapOffset, mapSize);
const mapped = new type(mappable.getMappedRange(mapOffset, mapSize));
const data = mapped.subarray(subarrayStart, typedLength) as T;
return {
data,
cleanup() {
mappable.unmap();
mappable.destroy();
},
};
}
/**
* Expect a GPUBuffer's contents to pass the provided check.
*
* A library of checks can be found in {@link webgpu/util/check_contents}.
*/
expectGPUBufferValuesPassCheck<T extends TypedArrayBufferView>(
src: GPUBuffer,
check: (actual: T) => Error | undefined,
{
srcByteOffset = 0,
type,
typedLength,
method = 'copy',
mode = 'fail',
}: {
srcByteOffset?: number;
type: TypedArrayBufferViewConstructor<T>;
typedLength: number;
method?: 'copy' | 'map';
mode?: 'fail' | 'warn';
}
) {
const readbackPromise = this.readGPUBufferRangeTyped(src, {
srcByteOffset,
type,
typedLength,
method,
});
this.eventualAsyncExpectation(async niceStack => {
const readback = await readbackPromise;
this.expectOK(check(readback.data), { mode, niceStack });
readback.cleanup();
});
}
/**
* Expect a GPUBuffer's contents to equal the values in the provided TypedArray.
*/
expectGPUBufferValuesEqual(
src: GPUBuffer,
expected: TypedArrayBufferView,
srcByteOffset: number = 0,
{ method = 'copy', mode = 'fail' }: { method?: 'copy' | 'map'; mode?: 'fail' | 'warn' } = {}
): void {
this.expectGPUBufferValuesPassCheck(src, a => checkElementsEqual(a, expected), {
srcByteOffset,
type: expected.constructor as TypedArrayBufferViewConstructor,
typedLength: expected.length,
method,
mode,
});
}
/**
* Expect a buffer to consist exclusively of rows of some repeated expected value. The size of
* `expectedValue` must be 1, 2, or any multiple of 4 bytes. Rows in the buffer are expected to be
* zero-padded out to `bytesPerRow`. `minBytesPerRow` is the number of bytes per row that contain
* actual (non-padding) data and must be an exact multiple of the byte-length of `expectedValue`.
*/
expectGPUBufferRepeatsSingleValue(
buffer: GPUBuffer,
{
expectedValue,
numRows,
minBytesPerRow,
bytesPerRow,
}: {
expectedValue: ArrayBuffer;
numRows: number;
minBytesPerRow: number;
bytesPerRow: number;
}
) {
const valueSize = expectedValue.byteLength;
assert(valueSize === 1 || valueSize === 2 || valueSize % 4 === 0);
assert(minBytesPerRow % valueSize === 0);
assert(bytesPerRow % 4 === 0);
// If the buffer is small enough, just generate the full expected buffer contents and check
// against them on the CPU.
const kMaxBufferSizeToCheckOnCpu = 256 * 1024;
const bufferSize = bytesPerRow * (numRows - 1) + minBytesPerRow;
if (bufferSize <= kMaxBufferSizeToCheckOnCpu) {
const valueBytes = Array.from(new Uint8Array(expectedValue));
const rowValues = new Array(minBytesPerRow / valueSize).fill(valueBytes);
const rowBytes = new Uint8Array([].concat(...rowValues));
const expectedContents = new Uint8Array(bufferSize);
range(numRows, row => expectedContents.set(rowBytes, row * bytesPerRow));
this.expectGPUBufferValuesEqual(buffer, expectedContents);
return;
}
// Copy into a buffer suitable for STORAGE usage.
const storageBuffer = this.device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
this.trackForCleanup(storageBuffer);
// This buffer conveys the data we expect to see for a single value read. Since we read 32 bits at
// a time, for values smaller than 32 bits we pad this expectation with repeated value data, or
// with zeroes if the width of a row in the buffer is less than 4 bytes. For value sizes larger
// than 32 bits, we assume they're a multiple of 32 bits and expect to read exact matches of
// `expectedValue` as-is.
const expectedDataSize = Math.max(4, valueSize);
const expectedDataBuffer = this.device.createBuffer({
size: expectedDataSize,
usage: GPUBufferUsage.STORAGE,
mappedAtCreation: true,
});
this.trackForCleanup(expectedDataBuffer);
const expectedData = new Uint32Array(expectedDataBuffer.getMappedRange());
if (valueSize === 1) {
const value = new Uint8Array(expectedValue)[0];
const values = new Array(Math.min(4, minBytesPerRow)).fill(value);
const padding = new Array(Math.max(0, 4 - values.length)).fill(0);
const expectedBytes = new Uint8Array(expectedData.buffer);
expectedBytes.set([...values, ...padding]);
} else if (valueSize === 2) {
const value = new Uint16Array(expectedValue)[0];
const expectedWords = new Uint16Array(expectedData.buffer);
expectedWords.set([value, minBytesPerRow > 2 ? value : 0]);
} else {
expectedData.set(new Uint32Array(expectedValue));
}
expectedDataBuffer.unmap();
// The output buffer has one 32-bit entry per buffer row. An entry's value will be 1 if every
// read from the corresponding row matches the expected data derived above, or 0 otherwise.
const resultBuffer = this.device.createBuffer({
size: numRows * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
this.trackForCleanup(resultBuffer);
const readsPerRow = Math.ceil(minBytesPerRow / expectedDataSize);
const reducer = `
struct Buffer { data: array<u32>, };
@group(0) @binding(0) var<storage, read> expected: Buffer;
@group(0) @binding(1) var<storage, read> in: Buffer;
@group(0) @binding(2) var<storage, read_write> out: Buffer;
@compute @workgroup_size(1) fn reduce(
@builtin(global_invocation_id) id: vec3<u32>) {
let rowBaseIndex = id.x * ${bytesPerRow / 4}u;
let readSize = ${expectedDataSize / 4}u;
out.data[id.x] = 1u;
for (var i: u32 = 0u; i < ${readsPerRow}u; i = i + 1u) {
let elementBaseIndex = rowBaseIndex + i * readSize;
for (var j: u32 = 0u; j < readSize; j = j + 1u) {
if (in.data[elementBaseIndex + j] != expected.data[j]) {
out.data[id.x] = 0u;
return;
}
}
}
}
`;
const pipeline = this.device.createComputePipeline({
layout: 'auto',
compute: {
module: this.device.createShaderModule({ code: reducer }),
entryPoint: 'reduce',
},
});
const bindGroup = this.device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: expectedDataBuffer } },
{ binding: 1, resource: { buffer: storageBuffer } },
{ binding: 2, resource: { buffer: resultBuffer } },
],
});
const commandEncoder = this.device.createCommandEncoder();
commandEncoder.copyBufferToBuffer(buffer, 0, storageBuffer, 0, bufferSize);
const pass = commandEncoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(numRows);
pass.end();
this.device.queue.submit([commandEncoder.finish()]);
const expectedResults = new Array(numRows).fill(1);
this.expectGPUBufferValuesEqual(resultBuffer, new Uint32Array(expectedResults));
}
// MAINTENANCE_TODO: add an expectContents for textures, which logs data: uris on failure
/**
* Expect an entire GPUTexture to have a single color at the given mip level (defaults to 0).
* MAINTENANCE_TODO: Remove this and/or replace it with a helper in TextureTestMixin.
*/
expectSingleColor(
src: GPUTexture,
format: GPUTextureFormat,
{
size,
exp,
dimension = '2d',
slice = 0,
layout,
}: {
size: [number, number, number];
exp: PerTexelComponent<number>;
dimension?: GPUTextureDimension;
slice?: number;
layout?: TextureLayoutOptions;
}
): void {
assert(
slice === 0 || dimension === '2d',
'texture slices are only implemented for 2d textures'
);
format = resolvePerAspectFormat(format, layout?.aspect);
const { byteLength, minBytesPerRow, bytesPerRow, rowsPerImage, mipSize } = getTextureCopyLayout(
format,
dimension,
size,
layout
);
// MAINTENANCE_TODO: getTextureCopyLayout does not return the proper size for array textures,
// i.e. it will leave the z/depth value as is instead of making it 1 when dealing with 2d
// texture arrays. Since we are passing in the dimension, we should update it to return the
// corrected size.
const copySize = [
mipSize[0],
dimension !== '1d' ? mipSize[1] : 1,
dimension === '3d' ? mipSize[2] : 1,
];
const rep = kTexelRepresentationInfo[format as EncodableTextureFormat];
const expectedTexelData = rep.pack(rep.encode(exp));
const buffer = this.device.createBuffer({
size: byteLength,
usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
this.trackForCleanup(buffer);
const commandEncoder = this.device.createCommandEncoder();
commandEncoder.copyTextureToBuffer(
{
texture: src,
mipLevel: layout?.mipLevel,
origin: { x: 0, y: 0, z: slice },
aspect: layout?.aspect,
},
{ buffer, bytesPerRow, rowsPerImage },
copySize
);
this.queue.submit([commandEncoder.finish()]);
this.expectGPUBufferRepeatsSingleValue(buffer, {
expectedValue: expectedTexelData,
numRows: rowsPerImage * copySize[2],
minBytesPerRow,
bytesPerRow,
});
}
/**
* Return a GPUBuffer that data are going to be written into.
* MAINTENANCE_TODO: Remove this once expectSinglePixelBetweenTwoValuesIn2DTexture is removed.
*/
private readSinglePixelFrom2DTexture(
src: GPUTexture,
format: SizedTextureFormat,
{ x, y }: { x: number; y: number },
{ slice = 0, layout }: { slice?: number; layout?: TextureLayoutOptions }
): GPUBuffer {
const { byteLength, bytesPerRow, rowsPerImage } = getTextureSubCopyLayout(
format,
[1, 1],
layout
);
const buffer = this.device.createBuffer({
size: byteLength,
usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
this.trackForCleanup(buffer);
const commandEncoder = this.device.createCommandEncoder();
commandEncoder.copyTextureToBuffer(
{ texture: src, mipLevel: layout?.mipLevel, origin: { x, y, z: slice } },
{ buffer, bytesPerRow, rowsPerImage },
[1, 1]
);
this.queue.submit([commandEncoder.finish()]);
return buffer;
}
/**
* Take a single pixel of a 2D texture, interpret it using a TypedArray of the `expected` type,
* and expect each value in that array to be between the corresponding "expected" values
* (either `a[i] <= actual[i] <= b[i]` or `a[i] >= actual[i] => b[i]`).
* MAINTENANCE_TODO: Remove this once there is a way to deal with undefined lerp-ed values.
*/
expectSinglePixelBetweenTwoValuesIn2DTexture(
src: GPUTexture,
format: SizedTextureFormat,
{ x, y }: { x: number; y: number },
{
exp,
slice = 0,
layout,
generateWarningOnly = false,
checkElementsBetweenFn = (act, [a, b]) => checkElementsBetween(act, [i => a[i], i => b[i]]),
}: {
exp: [TypedArrayBufferView, TypedArrayBufferView];
slice?: number;
layout?: TextureLayoutOptions;
generateWarningOnly?: boolean;
checkElementsBetweenFn?: (
actual: TypedArrayBufferView,
expected: readonly [TypedArrayBufferView, TypedArrayBufferView]
) => Error | undefined;
}
): void {
assert(exp[0].constructor === exp[1].constructor);
const constructor = exp[0].constructor as TypedArrayBufferViewConstructor;
assert(exp[0].length === exp[1].length);
const typedLength = exp[0].length;
const buffer = this.readSinglePixelFrom2DTexture(src, format, { x, y }, { slice, layout });
this.expectGPUBufferValuesPassCheck(buffer, a => checkElementsBetweenFn(a, exp), {
type: constructor,
typedLength,
mode: generateWarningOnly ? 'warn' : 'fail',
});
}
/**
* Emulate a texture to buffer copy by using a compute shader
* to load texture value of a single pixel and write to a storage buffer.
* For sample count == 1, the buffer contains only one value of the sample.
* For sample count > 1, the buffer contains (N = sampleCount) values sorted
* in the order of their sample index [0, sampleCount - 1]
*
* This can be useful when the texture to buffer copy is not available to the texture format
* e.g. (depth24plus), or when the texture is multisampled.
*
* MAINTENANCE_TODO: extend to read multiple pixels with given origin and size.
*
* @returns storage buffer containing the copied value from the texture.
*/
copySinglePixelTextureToBufferUsingComputePass(
type: ScalarType,
componentCount: number,
textureView: GPUTextureView,
sampleCount: number
): GPUBuffer {
const textureSrcCode =
sampleCount === 1
? `@group(0) @binding(0) var src: texture_2d<${type}>;`
: `@group(0) @binding(0) var src: texture_multisampled_2d<${type}>;`;
const code = `
struct Buffer {
data: array<${type}>,
};
${textureSrcCode}
@group(0) @binding(1) var<storage, read_write> dst : Buffer;
@compute @workgroup_size(1) fn main() {
var coord = vec2<i32>(0, 0);
for (var sampleIndex = 0; sampleIndex < ${sampleCount};
sampleIndex = sampleIndex + 1) {
let o = sampleIndex * ${componentCount};
let v = textureLoad(src, coord, sampleIndex);
for (var component = 0; component < ${componentCount}; component = component + 1) {
dst.data[o + component] = v[component];
}
}
}
`;
const computePipeline = this.device.createComputePipeline({
layout: 'auto',
compute: {
module: this.device.createShaderModule({
code,
}),
entryPoint: 'main',
},
});
const storageBuffer = this.device.createBuffer({
size: sampleCount * type.size * componentCount,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
this.trackForCleanup(storageBuffer);
const uniformBindGroup = this.device.createBindGroup({
layout: computePipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: textureView,
},
{
binding: 1,
resource: {
buffer: storageBuffer,
},
},
],
});
const encoder = this.device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, uniformBindGroup);
pass.dispatchWorkgroups(1);
pass.end();
this.device.queue.submit([encoder.finish()]);
return storageBuffer;
}
/**
* Expect the specified WebGPU error to be generated when running the provided function.
*/
expectGPUError<R>(filter: GPUErrorFilter, fn: () => R, shouldError: boolean = true): R {
// If no error is expected, we let the scope surrounding the test catch it.
if (!shouldError) {
return fn();
}
this.device.pushErrorScope(filter);
const returnValue = fn();
const promise = this.device.popErrorScope();
this.eventualAsyncExpectation(async niceStack => {
const error = await promise;
let failed = false;
switch (filter) {
case 'out-of-memory':
failed = !(error instanceof GPUOutOfMemoryError);
break;
case 'validation':
failed = !(error instanceof GPUValidationError);
break;
}
if (failed) {
niceStack.message = `Expected ${filter} error`;
this.rec.expectationFailed(niceStack);
} else {
niceStack.message = `Captured ${filter} error`;
if (error instanceof GPUValidationError) {
niceStack.message += ` - ${error.message}`;
}
this.rec.debug(niceStack);
}
});
return returnValue;
}
/**
* Expect a validation error inside the callback.
*
* Tests should always do just one WebGPU call in the callback, to make sure that's what's tested.
*/
expectValidationError(fn: () => void, shouldError: boolean = true): void {
// If no error is expected, we let the scope surrounding the test catch it.
if (shouldError) {
this.device.pushErrorScope('validation');
}
// Note: A return value is not allowed for the callback function. This is to avoid confusion
// about what the actual behavior would be; either of the following could be reasonable:
// - Make expectValidationError async, and have it await on fn(). This causes an async split
// between pushErrorScope and popErrorScope, so if the caller doesn't `await` on
// expectValidationError (either accidentally or because it doesn't care to do so), then
// other test code will be (nondeterministically) caught by the error scope.
// - Make expectValidationError NOT await fn(), but just execute its first block (until the
// first await) and return the return value (a Promise). This would be confusing because it
// would look like the error scope includes the whole async function, but doesn't.
// If we do decide we need to return a value, we should use the latter semantic.
const returnValue = fn() as unknown;
assert(
returnValue === undefined,
'expectValidationError callback should not return a value (or be async)'
);
if (shouldError) {
const promise = this.device.popErrorScope();
this.eventualAsyncExpectation(async niceStack => {
const gpuValidationError = await promise;
if (!gpuValidationError) {
niceStack.message = 'Validation succeeded unexpectedly.';
this.rec.validationFailed(niceStack);
} else if (gpuValidationError instanceof GPUValidationError) {
niceStack.message = `Validation failed, as expected - ${gpuValidationError.message}`;
this.rec.debug(niceStack);
}
});
}
}
/**
* Expects that the device should be lost for a particular reason at the teardown of the test.
*/
expectDeviceLost(reason: GPUDeviceLostReason): void {
assert(this.provider !== undefined, 'internal error: GPUDevice missing?');
this.provider.expectDeviceLost(reason);
}
/**
* Create a GPUBuffer with the specified contents and usage.
*
* MAINTENANCE_TODO: Several call sites would be simplified if this took ArrayBuffer as well.
*/
makeBufferWithContents(dataArray: TypedArrayBufferView, usage: GPUBufferUsageFlags): GPUBuffer {
return this.trackForCleanup(makeBufferWithContents(this.device, dataArray, usage));
}
/**
* Returns a GPUCommandEncoder, GPUComputePassEncoder, GPURenderPassEncoder, or
* GPURenderBundleEncoder, and a `finish` method returning a GPUCommandBuffer.
* Allows testing methods which have the same signature across multiple encoder interfaces.
*
* @example
* ```
* g.test('popDebugGroup')
* .params(u => u.combine('encoderType', kEncoderTypes))
* .fn(t => {
* const { encoder, finish } = t.createEncoder(t.params.encoderType);
* encoder.popDebugGroup();
* });
*
* g.test('writeTimestamp')
* .params(u => u.combine('encoderType', ['non-pass', 'compute pass', 'render pass'] as const)
* .fn(t => {
* const { encoder, finish } = t.createEncoder(t.params.encoderType);
* // Encoder type is inferred, so `writeTimestamp` can be used even though it doesn't exist
* // on GPURenderBundleEncoder.
* encoder.writeTimestamp(args);
* });
* ```
*/
createEncoder<T extends EncoderType>(
encoderType: T,
{
attachmentInfo,
occlusionQuerySet,
}: {
attachmentInfo?: GPURenderBundleEncoderDescriptor;
occlusionQuerySet?: GPUQuerySet;
} = {}
): CommandBufferMaker<T> {
const fullAttachmentInfo = {
// Defaults if not overridden:
colorFormats: ['rgba8unorm'],
sampleCount: 1,
// Passed values take precedent.
...attachmentInfo,
} as const;
switch (encoderType) {
case 'non-pass': {
const encoder = this.device.createCommandEncoder();
return new CommandBufferMaker(this, encoder, () => {
return encoder.finish();
});
}
case 'render bundle': {
const device = this.device;
const rbEncoder = device.createRenderBundleEncoder(fullAttachmentInfo);
const pass = this.createEncoder('render pass', { attachmentInfo });
return new CommandBufferMaker(this, rbEncoder, () => {
pass.encoder.executeBundles([rbEncoder.finish()]);
return pass.finish();
});
}
case 'compute pass': {
const commandEncoder = this.device.createCommandEncoder();
const encoder = commandEncoder.beginComputePass();
return new CommandBufferMaker(this, encoder, () => {
encoder.end();
return commandEncoder.finish();
});
}
case 'render pass': {
const makeAttachmentView = (format: GPUTextureFormat) =>
this.trackForCleanup(
this.device.createTexture({
size: [16, 16, 1],
format,
usage: GPUTextureUsage.RENDER_ATTACHMENT,
sampleCount: fullAttachmentInfo.sampleCount,
})
).createView();
let depthStencilAttachment: GPURenderPassDepthStencilAttachment | undefined = undefined;
if (fullAttachmentInfo.depthStencilFormat !== undefined) {
depthStencilAttachment = {
view: makeAttachmentView(fullAttachmentInfo.depthStencilFormat),
depthReadOnly: fullAttachmentInfo.depthReadOnly,
stencilReadOnly: fullAttachmentInfo.stencilReadOnly,
};
if (
kTextureFormatInfo[fullAttachmentInfo.depthStencilFormat].depth &&
!fullAttachmentInfo.depthReadOnly
) {
depthStencilAttachment.depthClearValue = 0;
depthStencilAttachment.depthLoadOp = 'clear';
depthStencilAttachment.depthStoreOp = 'discard';
}
if (
kTextureFormatInfo[fullAttachmentInfo.depthStencilFormat].stencil &&
!fullAttachmentInfo.stencilReadOnly
) {
depthStencilAttachment.stencilClearValue = 1;
depthStencilAttachment.stencilLoadOp = 'clear';
depthStencilAttachment.stencilStoreOp = 'discard';
}
}
const passDesc: GPURenderPassDescriptor = {
colorAttachments: Array.from(fullAttachmentInfo.colorFormats, format =>
format
? {
view: makeAttachmentView(format),
clearValue: [0, 0, 0, 0],
loadOp: 'clear',
storeOp: 'store',
}
: null
),
depthStencilAttachment,
occlusionQuerySet,
};
const commandEncoder = this.device.createCommandEncoder();
const encoder = commandEncoder.beginRenderPass(passDesc);
return new CommandBufferMaker(this, encoder, () => {
encoder.end();
return commandEncoder.finish();
});
}
}
unreachable();
}
}
/**
* Texture expectation mixin can be applied on top of GPUTest to add texture
* related expectation helpers.
*/
export interface TextureTestMixinType {
/**
* Creates a 1 mip level texture with the contents of a TexelView and tracks
* it for destruction for the test case.
*/
createTextureFromTexelView(
texelView: TexelView,
desc: Omit<GPUTextureDescriptor, 'format'>
): GPUTexture;
/**
* Creates a mipmapped texture where each mipmap level's (`i`) content is
* from `texelViews[i]` and tracks it for destruction for the test case.
*/
createTextureFromTexelViewsMultipleMipmaps(
texelViews: TexelView[],
desc: Omit<GPUTextureDescriptor, 'format'>
): GPUTexture;
/**
* Expects that comparing the subrect (defined via `size`) of a GPUTexture
* to the expected TexelView passes without error.
*/
expectTexelViewComparisonIsOkInTexture(
src: GPUImageCopyTexture,
exp: TexelView,
size: GPUExtent3D,
comparisonOptions?: TexelCompareOptions
): void;
/**
* Expects that a sparse set of pixels in the GPUTexture passes comparison against
* their expected colors without error.
*/
expectSinglePixelComparisonsAreOkInTexture<E extends PixelExpectation>(
src: GPUImageCopyTexture,
exp: PerPixelComparison<E>[],
comparisonOptions?: TexelCompareOptions
): void;
}
export function TextureTestMixin<F extends FixtureClass<GPUTest>>(
Base: F
): FixtureClassWithMixin<F, TextureTestMixinType> {
class TextureExpectations
extends (Base as FixtureClassInterface<GPUTest>)
implements TextureTestMixinType {
createTextureFromTexelView(
texelView: TexelView,
desc: Omit<GPUTextureDescriptor, 'format'>
): GPUTexture {
return this.trackForCleanup(createTextureFromTexelView(this.device, texelView, desc));
}
createTextureFromTexelViewsMultipleMipmaps(
texelViews: TexelView[],
desc: Omit<GPUTextureDescriptor, 'format'>
): GPUTexture {
return this.trackForCleanup(createTextureFromTexelViews(this.device, texelViews, desc));
}
expectTexelViewComparisonIsOkInTexture(
src: GPUImageCopyTexture,
exp: TexelView,
size: GPUExtent3D,
comparisonOptions = {
maxIntDiff: 0,
maxDiffULPsForNormFormat: 1,
maxDiffULPsForFloatFormat: 1,
}
): void {
this.eventualExpectOK(
textureContentIsOKByT2B(this, src, size, { expTexelView: exp }, comparisonOptions)
);
}
expectSinglePixelComparisonsAreOkInTexture<E extends PixelExpectation>(
src: GPUImageCopyTexture,
exp: PerPixelComparison<E>[],
comparisonOptions = {
maxIntDiff: 0,
maxDiffULPsForNormFormat: 1,
maxDiffULPsForFloatFormat: 1,
}
): void {
assert(exp.length > 0, 'must specify at least one pixel comparison');
assert(
(kEncodableTextureFormats as GPUTextureFormat[]).includes(src.texture.format),
() => `${src.texture.format} is not an encodable format`
);
const lowerCorner = [src.texture.width, src.texture.height, src.texture.depthOrArrayLayers];
const upperCorner = [0, 0, 0];
const expMap = new Map<string, E>();
const coords: Required<GPUOrigin3DDict>[] = [];
for (const e of exp) {
const coord = reifyOrigin3D(e.coord);
const coordKey = JSON.stringify(coord);
coords.push(coord);
// Compute the minimum sub-rect that encompasses all the pixel comparisons. The
// `lowerCorner` will become the origin, and the `upperCorner` will be used to compute the
// size.
lowerCorner[0] = Math.min(lowerCorner[0], coord.x);
lowerCorner[1] = Math.min(lowerCorner[1], coord.y);
lowerCorner[2] = Math.min(lowerCorner[2], coord.z);
upperCorner[0] = Math.max(upperCorner[0], coord.x);
upperCorner[1] = Math.max(upperCorner[1], coord.y);
upperCorner[2] = Math.max(upperCorner[2], coord.z);
// Build a sparse map of the coordinates to the expected colors for the texel view.
assert(
!expMap.has(coordKey),
() => `duplicate pixel expectation at coordinate (${coord.x},${coord.y},${coord.z})`
);
expMap.set(coordKey, e.exp);
}
const size: GPUExtent3D = [
upperCorner[0] - lowerCorner[0] + 1,
upperCorner[1] - lowerCorner[1] + 1,
upperCorner[2] - lowerCorner[2] + 1,
];
let expTexelView: TexelView;
if (Symbol.iterator in exp[0].exp) {
expTexelView = TexelView.fromTexelsAsBytes(
src.texture.format as EncodableTextureFormat,
coord => {
const res = expMap.get(JSON.stringify(coord));
assert(
res !== undefined,
() => `invalid coordinate (${coord.x},${coord.y},${coord.z}) in sparse texel view`
);
return res as Uint8Array;
}
);
} else {
expTexelView = TexelView.fromTexelsAsColors(
src.texture.format as EncodableTextureFormat,
coord => {
const res = expMap.get(JSON.stringify(coord));
assert(
res !== undefined,
() => `invalid coordinate (${coord.x},${coord.y},${coord.z}) in sparse texel view`
);
return res as PerTexelComponent<number>;
}
);
}
const coordsF = (function* () {
for (const coord of coords) {
yield coord;
}
})();
this.eventualExpectOK(
textureContentIsOKByT2B(
this,
{ ...src, origin: reifyOrigin3D(lowerCorner) },
size,
{ expTexelView },
comparisonOptions,
coordsF
)
);
}
}
return (TextureExpectations as unknown) as FixtureClassWithMixin<F, TextureTestMixinType>;
}