blob: 8def90598d2aee5d55e3438eab29f87a4de268f2 [file] [log] [blame]
import {
Fixture,
FixtureClass,
FixtureClassInterface,
FixtureClassWithMixin,
SubcaseBatchState,
TestCaseRecorder,
TestParams,
} from '../common/framework/fixture.js';
import { registerShutdownTask } from '../common/framework/on_shutdown.js';
import { globalTestConfig, isCompatibilityDevice } from '../common/framework/test_config.js';
import { getGPU } from '../common/util/navigator_gpu.js';
import {
assert,
makeValueTestVariant,
memcpy,
range,
ValueTestVariant,
TypedArrayBufferView,
TypedArrayBufferViewConstructor,
unreachable,
hasFeature,
} from '../common/util/util.js';
import { kPossibleLimits, kQueryTypeInfo, WGSLLanguageFeature } from './capability_info.js';
import { InterpolationType, InterpolationSampling } from './constants.js';
import {
resolvePerAspectFormat,
SizedTextureFormat,
EncodableTextureFormat,
isCompressedTextureFormat,
getRequiredFeatureForTextureFormat,
isTextureFormatUsableAsRenderAttachment,
isTextureFormatMultisampled,
isTextureFormatResolvable,
isDepthTextureFormat,
isStencilTextureFormat,
textureViewDimensionAndFormatCompatibleForDevice,
textureDimensionAndFormatCompatibleForDevice,
isTextureFormatUsableWithStorageAccessMode,
isTextureFormatUsableWithCopyExternalImageToTexture,
isTextureFormatFilterable,
isTextureFormatBlendable,
} from './format_info.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 {
CanonicalDeviceDescriptor,
DescriptorModifier,
DevicePool,
DeviceProvider,
UncanonicalizedDeviceDescriptor,
} from './util/device_pool.js';
import { align, roundDown } from './util/math.js';
import {
getTextureCopyLayout,
getTextureSubCopyLayout,
LayoutOptions as TextureLayoutOptions,
} from './util/texture/layout.js';
import { PerTexelComponent, kTexelRepresentationInfo } from './util/texture/texel_data.js';
import { reifyExtent3D, reifyOrigin3D } from './util/unions.js';
// Declarations for WebGPU items we want tests for that are not yet officially part of the spec.
declare global {
// MAINTENANCE_TODO: remove once added to @webgpu/types
interface GPUSupportedLimits {
readonly maxStorageBuffersInFragmentStage?: number;
readonly maxStorageTexturesInFragmentStage?: number;
readonly maxStorageBuffersInVertexStage?: number;
readonly maxStorageTexturesInVertexStage?: number;
}
}
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();
// On shutdown, try to explicitly destroy() the device pools (and devices) used by GPUTest,
// so they don't keep using system resources until they're fully garbage collected.
registerShutdownTask(() => {
devicePool.destroy();
mismatchedDevicePool.destroy();
});
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. */
export type DeviceSelectionDescriptor =
| UncanonicalizedDeviceDescriptor
| GPUFeatureName
| undefined
| Array<GPUFeatureName | undefined>;
export function initUncanonicalizedDeviceDescriptor(
descriptor: DeviceSelectionDescriptor
): UncanonicalizedDeviceDescriptor {
if (typeof descriptor === 'string') {
return { requiredFeatures: [descriptor] };
} else if (descriptor instanceof Array) {
return {
requiredFeatures: descriptor.filter(f => f !== undefined) as GPUFeatureName[],
};
} else {
return descriptor ?? {};
}
}
type DeviceDescriptorSimplified = {
requiredFeatures: GPUFeatureName[];
requiredLimits: Record<string, number>;
defaultQueue: GPUQueueDescriptor;
};
function mergeDeviceSelectionDescriptorIntoDeviceDescriptor(
src: DeviceSelectionDescriptor,
dst: DeviceDescriptorSimplified
) {
const srcFixed = initUncanonicalizedDeviceDescriptor(src);
if (srcFixed) {
dst.requiredFeatures.push(...(srcFixed.requiredFeatures ?? []));
Object.assign(dst.requiredLimits, srcFixed.requiredLimits ?? {});
}
}
export class GPUTestSubcaseBatchState extends SubcaseBatchState {
/** Provider for default device. */
private provider: Promise<DeviceProvider> | undefined;
/** Provider for mismatched device. */
private mismatchedProvider: Promise<DeviceProvider> | undefined;
/** The accumulated skip-if requirements for this subcase */
private skipIfRequirements: DeviceDescriptorSimplified = {
requiredFeatures: [],
requiredLimits: {},
defaultQueue: {},
};
/** Whether or not to provide a mismatched device */
private useMismatchedDevice = false;
override async postInit(): Promise<void> {
// Skip all subcases if there's no device.
await this.acquireProvider();
}
override async finalize(): Promise<void> {
await super.finalize();
// Ensure devicePool.release is called for both providers even if one rejects
// and wait for both of them before proceeding.
const results = await Promise.allSettled([
this.provider?.then(x => devicePool.release(x)),
this.mismatchedProvider?.then(x => mismatchedDevicePool.release(x)),
]);
// If one of them rejected throw its reason. It should be an `Error`.
for (const result of results) {
if (result.status === 'rejected') throw result.reason;
}
}
/** @internal MAINTENANCE_TODO: Make this not visible to test code? */
acquireProvider(): Promise<DeviceProvider> {
if (this.provider === undefined) {
this.requestDeviceWithRequiredParametersOrSkip(this.skipIfRequirements);
}
assert(this.provider !== undefined);
assert(!this.useMismatchedDevice || this.mismatchedProvider !== undefined);
return this.provider;
}
get isCompatibility() {
return globalTestConfig.compatibility;
}
/**
* 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.
*/
requestDeviceWithRequiredParametersOrSkip(
descriptor: DeviceSelectionDescriptor,
descriptorModifier?: DescriptorModifier
): void {
assert(this.provider === undefined, "Can't selectDeviceOrSkipTestCase() multiple times");
this.provider = devicePool.acquire(
this.recorder,
initUncanonicalizedDeviceDescriptor(descriptor),
descriptorModifier
);
// Suppress uncaught promise rejection (we'll catch it later).
this.provider.catch(() => {});
if (this.useMismatchedDevice) {
this.mismatchedProvider = mismatchedDevicePool.acquire(
this.recorder,
initUncanonicalizedDeviceDescriptor(descriptor),
descriptorModifier
);
// Suppress uncaught promise rejection (we'll catch it later).
this.mismatchedProvider.catch(() => {});
}
}
/**
* 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. The second device will be created with the
* same features and limits as the first device.
*/
usesMismatchedDevice() {
assert(this.provider === undefined, 'Can not call usedMismatchedDevice after device creation');
this.useMismatchedDevice = true;
}
/**
* 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 add
* features or limits required by the subcase. If the features or limits are not
* available a SkipTestCase exception will be thrown to skip the entire test case.
*/
selectDeviceOrSkipTestCase(descriptor: DeviceSelectionDescriptor): void {
mergeDeviceSelectionDescriptorIntoDeviceDescriptor(descriptor, this.skipIfRequirements);
}
/**
* 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(getRequiredFeatureForTextureFormat(format));
}
}
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;
}
skipIfCopyTextureToTextureNotSupportedForFormat(...formats: (GPUTextureFormat | undefined)[]) {
if (this.isCompatibility) {
for (const format of formats) {
if (format && isCompressedTextureFormat(format)) {
this.skip(`copyTextureToTexture with ${format} is not supported in compatibility mode`);
}
}
}
}
/**
* Skips test if the given interpolation type or sampling is not supported.
*/
skipIfInterpolationTypeOrSamplingNotSupported({
type,
sampling,
}: {
type?: InterpolationType;
sampling?: InterpolationSampling;
}) {
if (this.isCompatibility) {
this.skipIf(
type === 'linear',
'interpolation type linear is not supported in compatibility mode'
);
this.skipIf(
sampling === 'sample',
'interpolation type linear is not supported in compatibility mode'
);
this.skipIf(
type === 'flat' && (!sampling || sampling === 'first'),
'interpolation type flat with sampling not set to either is not supported in compatibility mode'
);
}
}
/** Skips this test case if the `langFeature` is *not* supported. */
skipIfLanguageFeatureNotSupported(langFeature: WGSLLanguageFeature) {
if (!this.hasLanguageFeature(langFeature)) {
this.skip(`WGSL language feature '${langFeature}' is not supported`);
}
}
/** Skips this test case if the `langFeature` is supported. */
skipIfLanguageFeatureSupported(langFeature: WGSLLanguageFeature) {
if (this.hasLanguageFeature(langFeature)) {
this.skip(`WGSL language feature '${langFeature}' is supported`);
}
}
/** returns true iff the `langFeature` is supported */
hasLanguageFeature(langFeature: WGSLLanguageFeature) {
const lf = getGPU(this.recorder).wgslLanguageFeatures;
return lf !== undefined && lf.has(langFeature);
}
}
/**
* Base fixture for WebGPU tests.
*
* This class is a Fixture + a getter that returns a GPUDevice
* as well as helpers that use that device.
*/
export class GPUTestBase extends Fixture<GPUTestSubcaseBatchState> {
public static override MakeSharedState(
recorder: TestCaseRecorder,
params: TestParams
): GPUTestSubcaseBatchState {
return new GPUTestSubcaseBatchState(recorder, params);
}
// This must be overridden in derived classes
get device(): GPUDevice {
unreachable();
return null as unknown as GPUDevice;
}
/** GPUQueue for the test to use. (Same as `t.device.queue`.) */
get queue(): GPUQueue {
return this.device.queue;
}
get isCompatibility() {
return globalTestConfig.compatibility;
}
makeLimitVariant(limit: (typeof kPossibleLimits)[number], variant: ValueTestVariant) {
return makeValueTestVariant(this.device.limits[limit]!, variant);
}
canCallCopyTextureToBufferWithTextureFormat(format: GPUTextureFormat) {
return !this.isCompatibility || !isCompressedTextureFormat(format);
}
/** 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.createBufferTracked({
label: 'createCopyForMapRead',
size,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
const c = this.device.createCommandEncoder({ label: 'createCopyForMapRead' });
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();
},
};
}
/**
* Skips test if device does not have feature.
* Note: Try to use one of the more specific skipIf tests if possible.
*/
skipIfDeviceDoesNotHaveFeature(feature: GPUFeatureName) {
this.skipIf(
!hasFeature(this.device.features, feature),
`device does not have feature: '${feature}'`
);
}
/**
* Skips test if device des not support query type.
*/
skipIfDeviceDoesNotSupportQueryType(...types: GPUQueryType[]) {
for (const type of types) {
const feature = kQueryTypeInfo[type].feature;
if (feature) {
this.skipIfDeviceDoesNotHaveFeature(feature);
}
}
}
skipIfDepthTextureCanNotBeUsedWithNonComparisonSampler() {
this.skipIf(
this.isCompatibility,
'depth textures are not usable with non-comparison samplers in compatibility mode'
);
}
/**
* Skips test if any format is not supported.
*/
skipIfTextureFormatNotSupported(...formats: (GPUTextureFormat | undefined)[]) {
for (const format of formats) {
if (!format) {
continue;
}
if (format === 'bgra8unorm-srgb') {
if (isCompatibilityDevice(this.device)) {
this.skip(`texture format '${format}' is not supported`);
}
}
const feature = getRequiredFeatureForTextureFormat(format);
this.skipIf(
!!feature && !hasFeature(this.device.features, feature),
`texture format '${format}' requires feature: '${feature}'`
);
}
}
skipIfTextureFormatAndViewDimensionNotCompatible(
format: GPUTextureFormat,
viewDimension: GPUTextureViewDimension
) {
this.skipIf(
!textureViewDimensionAndFormatCompatibleForDevice(this.device, viewDimension, format),
`format: ${format} does not support viewDimension: ${viewDimension}`
);
}
skipIfTextureFormatAndDimensionNotCompatible(
format: GPUTextureFormat,
dimension: GPUTextureDimension | undefined
) {
this.skipIf(
!textureDimensionAndFormatCompatibleForDevice(this.device, dimension, format),
`format: ${format} does not support dimension: ${dimension}`
);
}
skipIfTextureFormatNotResolvable(...formats: (GPUTextureFormat | undefined)[]) {
for (const format of formats) {
if (format === undefined) continue;
if (!isTextureFormatResolvable(this.device, format)) {
this.skip(`texture format '${format}' is not resolvable`);
}
}
}
skipIfTextureViewDimensionNotSupported(...dimensions: (GPUTextureViewDimension | undefined)[]) {
if (isCompatibilityDevice(this.device)) {
for (const dimension of dimensions) {
if (dimension === 'cube-array') {
this.skip(`texture view dimension '${dimension}' is not supported`);
}
}
}
}
skipIfCopyTextureToTextureNotSupportedForFormat(...formats: (GPUTextureFormat | undefined)[]) {
if (isCompatibilityDevice(this.device)) {
for (const format of formats) {
if (format && isCompressedTextureFormat(format)) {
this.skip(`copyTextureToTexture with ${format} is not supported`);
}
}
}
}
skipIfTextureLoadNotSupportedForTextureType(...types: (string | undefined | null)[]) {
if (this.isCompatibility) {
for (const type of types) {
switch (type) {
case 'texture_depth_2d':
case 'texture_depth_2d_array':
case 'texture_depth_multisampled_2d':
this.skip(`${type} is not supported by textureLoad in compatibility mode`);
}
}
}
}
skipIfTextureFormatNotUsableWithStorageAccessMode(
access: GPUStorageTextureAccess | 'read' | 'write' | 'read_write',
...formats: (GPUTextureFormat | undefined)[]
) {
for (const format of formats) {
if (!format) continue;
if (!isTextureFormatUsableWithStorageAccessMode(this.device, format, access)) {
this.skip(
`Texture with ${format} is not usable as a storage texture with access ${access}`
);
}
}
}
skipIfTextureFormatNotUsableAsRenderAttachment(...formats: (GPUTextureFormat | undefined)[]) {
for (const format of formats) {
if (format && !isTextureFormatUsableAsRenderAttachment(this.device, format)) {
this.skip(`Texture with ${format} is not usable as a render attachment`);
}
}
}
skipIfTextureFormatNotMultisampled(...formats: (GPUTextureFormat | undefined)[]) {
for (const format of formats) {
if (format === undefined) continue;
if (!isTextureFormatMultisampled(this.device, format)) {
this.skip(`texture format '${format}' does not support multisampling`);
}
}
}
skipIfTextureFormatNotBlendable(...formats: (GPUTextureFormat | undefined)[]) {
for (const format of formats) {
if (format === undefined) continue;
this.skipIf(!isTextureFormatBlendable(this.device, format), `${format} is not blendable`);
}
}
skipIfTextureFormatNotFilterable(...formats: (GPUTextureFormat | undefined)[]) {
for (const format of formats) {
if (format === undefined) continue;
this.skipIf(!isTextureFormatFilterable(this.device, format), `${format} is not filterable`);
}
}
skipIfTextureFormatDoesNotSupportUsage(
usage: GPUTextureUsageFlags,
...formats: (GPUTextureFormat | undefined)[]
) {
for (const format of formats) {
if (!format) continue;
if (usage & GPUTextureUsage.RENDER_ATTACHMENT) {
this.skipIfTextureFormatNotUsableAsRenderAttachment(format);
}
if (usage & GPUTextureUsage.STORAGE_BINDING) {
this.skipIfTextureFormatNotUsableWithStorageAccessMode('write-only', format);
}
}
}
skipIfTextureFormatDoesNotSupportCopyTextureToBuffer(format: GPUTextureFormat) {
this.skipIf(
!this.canCallCopyTextureToBufferWithTextureFormat(format),
`can not use copyTextureToBuffer with ${format}`
);
}
skipIfTextureFormatPossiblyNotUsableWithCopyExternalImageToTexture(format: GPUTextureFormat) {
this.skipIf(
!isTextureFormatUsableWithCopyExternalImageToTexture(this.device, format),
`can not use copyExternalImageToTexture with ${format}`
);
}
/** Skips this test case if the `langFeature` is *not* supported. */
skipIfLanguageFeatureNotSupported(langFeature: WGSLLanguageFeature) {
if (!this.hasLanguageFeature(langFeature)) {
this.skip(`WGSL language feature '${langFeature}' is not supported`);
}
}
/** Skips this test case if the `langFeature` is supported. */
skipIfLanguageFeatureSupported(langFeature: WGSLLanguageFeature) {
if (this.hasLanguageFeature(langFeature)) {
this.skip(`WGSL language feature '${langFeature}' is supported`);
}
}
/** returns true if the `langFeature` is supported */
hasLanguageFeature(langFeature: WGSLLanguageFeature) {
const lf = getGPU(this.rec).wgslLanguageFeatures;
return lf !== undefined && lf.has(langFeature);
}
/** Skips this test case if the GPUTextureUsage `TRANSIENT_ATTACHMENT` is *not* supported. */
// MAINTENANCE_TODO(#4509): Remove this when TRANSIENT_ATTACHMENT is added to the WebGPU spec.
skipIfTransientAttachmentNotSupported() {
const isTransientAttachmentSupported = 'TRANSIENT_ATTACHMENT' in GPUTextureUsage;
this.skipIf(
!isTransientAttachmentSupported,
'GPUTextureUsage TRANSIENT_ATTACHMENT is not supported'
);
}
/**
* 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.createBufferTracked({
label: 'expectGPUBufferRepeatsSingleValue:storageBuffer',
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// 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.createBufferTracked({
label: 'expectGPUBufferRepeatsSingleValue:expectedDataBuffer',
size: expectedDataSize,
usage: GPUBufferUsage.STORAGE,
mappedAtCreation: true,
});
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.createBufferTracked({
label: 'expectGPUBufferRepeatsSingleValue:resultBuffer',
size: numRows * 4,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
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({
label: 'expectGPUBufferRepeatsSingleValue',
});
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.createBufferTracked({
label: 'expectSingleColor',
size: byteLength,
usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
const commandEncoder = this.device.createCommandEncoder({ label: 'expectSingleColor' });
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.createBufferTracked({
label: 'readSinglePixelFrom2DTexture',
size: byteLength,
usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
});
const commandEncoder = this.device.createCommandEncoder({
label: 'readSinglePixelFrom2DTexture',
});
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] as number, i => b[i] as number]),
}: {
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 values of a subregion of a 2d texture and write to a storage buffer.
* For sample count == 1, the buffer contains extent[0] * extent[1] of the sample.
* For sample count > 1, the buffer contains extent[0] * extent[1] * (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 texture dimension to 1d and 3d.
*
* @returns storage buffer containing the copied value from the texture.
*/
copy2DTextureToBufferUsingComputePass(
type: ScalarType,
componentCount: number,
textureView: GPUTextureView,
sampleCount: number = 1,
extent_: GPUExtent3D = [1, 1, 1],
origin_: GPUOrigin3D = [0, 0, 0]
): GPUBuffer {
const origin = reifyOrigin3D(origin_);
const extent = reifyExtent3D(extent_);
const width = extent.width;
const height = extent.height;
const kWorkgroupSizeX = 8;
const kWorkgroupSizeY = 8;
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;
struct Params {
origin: vec2u,
extent: vec2u,
};
@group(0) @binding(2) var<uniform> params : Params;
@compute @workgroup_size(${kWorkgroupSizeX}, ${kWorkgroupSizeY}, 1) fn main(@builtin(global_invocation_id) id : vec3u) {
let boundary = params.origin + params.extent;
let coord = params.origin + id.xy;
if (any(coord >= boundary)) {
return;
}
let offset = (id.x + id.y * params.extent.x) * ${componentCount} * ${sampleCount};
for (var sampleIndex = 0u; sampleIndex < ${sampleCount};
sampleIndex = sampleIndex + 1) {
let o = offset + sampleIndex * ${componentCount};
let v = textureLoad(src, coord.xy, sampleIndex);
for (var component = 0u; 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.createBufferTracked({
label: 'copy2DTextureToBufferUsingComputePass:storageBuffer',
size: sampleCount * type.size * componentCount * width * height,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC,
});
const uniformBuffer = this.makeBufferWithContents(
new Uint32Array([origin.x, origin.y, width, height]),
GPUBufferUsage.UNIFORM
);
const uniformBindGroup = this.device.createBindGroup({
layout: computePipeline.getBindGroupLayout(0),
entries: [
{
binding: 0,
resource: textureView,
},
{
binding: 1,
resource: {
buffer: storageBuffer,
},
},
{
binding: 2,
resource: {
buffer: uniformBuffer,
},
},
],
});
const encoder = this.device.createCommandEncoder({
label: 'copy2DTextureToBufferUsingComputePass',
});
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, uniformBindGroup);
pass.dispatchWorkgroups(
Math.floor((width + kWorkgroupSizeX - 1) / kWorkgroupSizeX),
Math.floor((height + kWorkgroupSizeY - 1) / kWorkgroupSizeY),
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);
}
});
}
}
/**
* Expect a validation error or exception inside the callback.
*
* Tests should always do just one WebGPU call in the callback, to make sure that's what's tested.
*/
expectValidationErrorOrException(
fn: () => void,
shouldError: boolean = true,
shouldThrow: boolean = true
): void {
if (shouldThrow) {
this.shouldThrow(shouldError, fn);
} else {
this.expectValidationError(fn, shouldError);
}
}
/** Create a GPUBuffer and track it for cleanup at the end of the test. */
createBufferTracked(descriptor: GPUBufferDescriptor): GPUBuffer {
return this.trackForCleanup(this.device.createBuffer(descriptor));
}
/** Create a GPUTexture and track it for cleanup at the end of the test. */
createTextureTracked(descriptor: GPUTextureDescriptor): GPUTexture {
return this.trackForCleanup(this.device.createTexture(descriptor));
}
/** Create a GPUQuerySet and track it for cleanup at the end of the test. */
createQuerySetTracked(descriptor: GPUQuerySetDescriptor): GPUQuerySet {
return this.trackForCleanup(this.device.createQuerySet(descriptor));
}
/**
* Creates a buffer with the contents of some TypedArray.
* The buffer size will always be aligned to 4 as we set mappedAtCreation === true when creating the
* buffer.
*
* MAINTENANCE_TODO: Several call sites would be simplified if this took ArrayBuffer as well.
*/
makeBufferWithContents(dataArray: TypedArrayBufferView, usage: GPUBufferUsageFlags): GPUBuffer {
const buffer = this.createBufferTracked({
mappedAtCreation: true,
size: align(dataArray.byteLength, 4),
usage,
});
memcpy({ src: dataArray }, { dst: buffer.getMappedRange() });
buffer.unmap();
return buffer;
}
/**
* 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,
targets,
}: {
attachmentInfo?: GPURenderBundleEncoderDescriptor;
occlusionQuerySet?: GPUQuerySet;
targets?: GPUTextureView[];
} = {}
): 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, targets });
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.createTextureTracked({
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 (
isDepthTextureFormat(fullAttachmentInfo.depthStencilFormat) &&
!fullAttachmentInfo.depthReadOnly
) {
depthStencilAttachment.depthClearValue = 0;
depthStencilAttachment.depthLoadOp = 'clear';
depthStencilAttachment.depthStoreOp = 'discard';
}
if (
isStencilTextureFormat(fullAttachmentInfo.depthStencilFormat) &&
!fullAttachmentInfo.stencilReadOnly
) {
depthStencilAttachment.stencilClearValue = 1;
depthStencilAttachment.stencilLoadOp = 'clear';
depthStencilAttachment.stencilStoreOp = 'discard';
}
}
const passDesc: GPURenderPassDescriptor = {
colorAttachments: Array.from(fullAttachmentInfo.colorFormats, (format, i) =>
format
? {
view: targets ? targets[i] : 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();
}
}
/**
* Fixture for WebGPU tests that uses a DeviceProvider
*/
export class GPUTest extends GPUTestBase {
// Should never be undefined in a test. If it is, init() must not have run/finished.
private provider: DeviceProvider | undefined;
private mismatchedProvider: DeviceProvider | undefined;
override async init() {
await super.init();
this.provider = await this.sharedState.acquireProvider();
this.mismatchedProvider = await this.sharedState.acquireMismatchedProvider();
}
/** GPUAdapter that the device was created from. */
get adapter(): GPUAdapter {
assert(this.provider !== undefined, 'internal error: DeviceProvider missing');
return this.provider.adapter;
}
/**
* GPUDevice for the test to use.
*/
override get device(): GPUDevice {
assert(this.provider !== undefined, 'internal error: DeviceProvider 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,
'usesMismatchedDevice or selectMismatchedDeviceOrSkipTestCase was not called in beforeAllSubcases'
);
return this.mismatchedProvider.device;
}
/**
* 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);
}
}
/**
* Gets the adapter limits as a standard JavaScript object.
*/
function getAdapterLimitsAsDeviceRequiredLimits(adapter: GPUAdapter) {
const requiredLimits: Record<string, GPUSize64> = {};
const adapterLimits = adapter.limits as unknown as Record<string, GPUSize64>;
for (const key in adapter.limits) {
// MAINTENANCE_TODO: Remove this once minSubgroupSize is removed from
// chromium.
if (key === 'maxSubgroupSize' || key === 'minSubgroupSize') {
continue;
}
requiredLimits[key] = adapterLimits[key];
}
return requiredLimits;
}
/**
* Removes limits that don't exist on the adapter.
* A test might request a new limit that not all implementations support. The test itself
* should check the requested limit using code that expects undefined.
*
* ```ts
* t.skipIf(limit < 2); // BAD! Doesn't skip if unsupported because undefined is never less than 2.
* t.skipIf(!(limit >= 2)); // Good. Skips if limits is not >= 2. undefined is not >= 2.
* ```
*/
function removeNonExistentLimits(adapter: GPUAdapter, limits: Record<string, GPUSize64>) {
const filteredLimits: Record<string, GPUSize64> = {};
const adapterLimits = adapter.limits as unknown as Record<string, GPUSize64>;
for (const [limit, value] of Object.entries(limits)) {
if (adapterLimits[limit] !== undefined) {
filteredLimits[limit] = value;
}
}
return filteredLimits;
}
function applyLimitsToDescriptor(
adapter: GPUAdapter,
desc: CanonicalDeviceDescriptor | undefined,
getRequiredLimits: (adapter: GPUAdapter) => Record<string, number>
) {
const descWithMaxLimits: CanonicalDeviceDescriptor = {
requiredFeatures: [],
defaultQueue: {},
...desc,
requiredLimits: removeNonExistentLimits(adapter, getRequiredLimits(adapter)),
};
return descWithMaxLimits;
}
function getAdapterFeaturesAsDeviceRequiredFeatures(adapter: GPUAdapter): Iterable<GPUFeatureName> {
return [...adapter.features].filter(
f => f !== 'core-features-and-limits'
) as Iterable<GPUFeatureName>;
}
function applyFeaturesToDescriptor(
adapter: GPUAdapter,
desc: CanonicalDeviceDescriptor | undefined,
getRequiredFeatures: (adapter: GPUAdapter) => Iterable<GPUFeatureName>
) {
const existingRequiredFeatures = (desc && desc?.requiredFeatures) ?? [];
const descWithRequiredFeatures: CanonicalDeviceDescriptor = {
requiredLimits: {},
defaultQueue: {},
...desc,
requiredFeatures: [...existingRequiredFeatures, ...getRequiredFeatures(adapter)],
};
return descWithRequiredFeatures;
}
/**
* Used by RequiredLimitsTestMixin to allow you to request specific limits
*
* Supply a `getRequiredLimits` function that given a GPUAdapter, turns the limits
* you want.
*
* Also supply a key function that returns a device key. You should generally return
* the name of each limit you request and any math you did on the limit. For example
*
* ```js
* {
* getRequiredLimits(adapter) {
* return {
* maxBindGroups: adapter.limits.maxBindGroups / 2,
* maxTextureDimensions2D: Math.max(adapter.limits.maxTextureDimensions2D, 8192),
* },
* },
* key() {
* return `
* maxBindGroups / 2,
* max(maxTextureDimension2D, 8192),
* `;
* },
* }
* ```
*
* Its important to note, the key is used BEFORE knowing the adapter limits to get a device
* that was already created with the same key.
*/
interface RequiredLimitsHelper {
getRequiredLimits: (adapter: GPUAdapter) => Record<string, number>;
key(): string;
}
/**
* Used by RequiredLimitsTest to request a device with all requested limits of the adapter.
*/
export class RequiredLimitsGPUTestSubcaseBatchState extends GPUTestSubcaseBatchState {
private requiredLimitsHelper: RequiredLimitsHelper;
constructor(
protected override readonly recorder: TestCaseRecorder,
public override readonly params: TestParams,
requiredLimitsHelper: RequiredLimitsHelper
) {
super(recorder, params);
this.requiredLimitsHelper = requiredLimitsHelper;
}
override requestDeviceWithRequiredParametersOrSkip(
descriptor: DeviceSelectionDescriptor,
descriptorModifier?: DescriptorModifier
): void {
const requiredLimitsHelper = this.requiredLimitsHelper;
const mod: DescriptorModifier = {
descriptorModifier(adapter: GPUAdapter, desc: CanonicalDeviceDescriptor | undefined) {
desc = descriptorModifier?.descriptorModifier
? descriptorModifier.descriptorModifier(adapter, desc)
: desc;
return applyLimitsToDescriptor(adapter, desc, requiredLimitsHelper.getRequiredLimits);
},
keyModifier(baseKey: string) {
return `${baseKey}:${requiredLimitsHelper.key()}`;
},
};
super.requestDeviceWithRequiredParametersOrSkip(
initUncanonicalizedDeviceDescriptor(descriptor),
mod
);
}
}
export type RequiredLimitsTestMixinType = {
// placeholder. Change to an interface if we need MaxLimits specific methods.
};
/**
* A text mixin to make it relatively easy to request specific limits.
*/
export function RequiredLimitsTestMixin<F extends FixtureClass<GPUTestBase>>(
Base: F,
requiredLimitsHelper: RequiredLimitsHelper
): FixtureClassWithMixin<F, RequiredLimitsTestMixinType> {
class RequiredLimitsImpl
extends (Base as FixtureClassInterface<GPUTestBase>)
implements RequiredLimitsTestMixinType
{
//
public static override MakeSharedState(
recorder: TestCaseRecorder,
params: TestParams
): GPUTestSubcaseBatchState {
return new RequiredLimitsGPUTestSubcaseBatchState(recorder, params, requiredLimitsHelper);
}
}
return RequiredLimitsImpl as unknown as FixtureClassWithMixin<F, RequiredLimitsTestMixinType>;
}
/**
* Used by AllFeaturesMaxLimitsGPUTest to request a device with all limits and features of the adapter.
*/
export class AllFeaturesMaxLimitsGPUTestSubcaseBatchState extends GPUTestSubcaseBatchState {
constructor(
protected override readonly recorder: TestCaseRecorder,
public override readonly params: TestParams
) {
super(recorder, params);
}
override requestDeviceWithRequiredParametersOrSkip(
descriptor: DeviceSelectionDescriptor,
descriptorModifier?: DescriptorModifier
): void {
const mod: DescriptorModifier = {
descriptorModifier(adapter: GPUAdapter, desc: CanonicalDeviceDescriptor | undefined) {
desc = descriptorModifier?.descriptorModifier
? descriptorModifier.descriptorModifier(adapter, desc)
: desc;
desc = applyLimitsToDescriptor(adapter, desc, getAdapterLimitsAsDeviceRequiredLimits);
desc = applyFeaturesToDescriptor(adapter, desc, getAdapterFeaturesAsDeviceRequiredFeatures);
return desc;
},
keyModifier(baseKey: string) {
return `${baseKey}:AllFeaturesMaxLimits`;
},
};
super.requestDeviceWithRequiredParametersOrSkip(
initUncanonicalizedDeviceDescriptor(descriptor),
mod
);
}
/**
* Use skipIfDeviceDoesNotHaveFeature or similar. If you really need to test
* lack of a feature (for example tests under webgpu/api/validation/capability_checks)
* then use UniqueFeaturesOrLimitsGPUTest
*/
override selectDeviceOrSkipTestCase(descriptor: DeviceSelectionDescriptor): void {
unreachable('this function should not be called in AllFeaturesMaxLimitsGPUTest');
}
/**
* Use skipIfDeviceDoesNotHaveFeature or similar.
*/
override selectDeviceForQueryTypeOrSkipTestCase(types: GPUQueryType | GPUQueryType[]): void {
unreachable('this function should not be called in AllFeaturesMaxLimitsGPUTest');
}
/**
* Use skipIfDeviceDoesNotHaveFeature or skipIf(device.limits.maxXXX < requiredXXX) etc...
*/
override selectDeviceForTextureFormatOrSkipTestCase(
formats: GPUTextureFormat | undefined | (GPUTextureFormat | undefined)[]
): void {
unreachable('this function should not be called in AllFeaturesMaxLimitsGPUTest');
}
/**
* Use skipIfDeviceDoesNotHaveFeature or skipIf(device.limits.maxXXX < requiredXXX) etc...
*/
selectMismatchedDeviceOrSkipTestCase(descriptor: DeviceSelectionDescriptor): void {
unreachable('this function should not be called in AllFeaturesMaxLimitsGPUTest');
}
}
/**
* Most tests should be using `AllFeaturesMaxLimitsGPUTest`. The exceptions
* are tests specifically validating limits like those under api/validation/capability_checks/limits
* and those tests the specifically validate certain features fail validation if not enabled
* like those under api/validation/capability_checks/feature.
*
* NOTE: The goal is to go through all existing tests and remove any direct use of GPUTest.
* For each test, choose either AllFeaturesMaxLimitsGPUTest or UniqueFeaturesOrLimitsGPUTest.
* This way we can track progress as we go through every test using GPUTest and check it is
* testing everything it should test.
*/
export class UniqueFeaturesOrLimitsGPUTest extends GPUTest {}
/**
* A test that requests all features and maximum limits. This should be the default
* test for the majority of tests, otherwise optional features will not be tested.
* The exceptions are only tests that explicitly test the absence of a feature or
* specific limits such as the tests under validation/capability_checks.
*
* As a concrete example to demonstrate the issue, texture format `rg11b10ufloat` is
* optionally renderable and can optionally be used multisampled. Any test that tests
* texture formats should test this format, skipping only if the feature is missing.
* So, the default should be that the test tests `kAllTextureFormats` with the appropriate
* filters from format_info.ts or the various helpers. This way, `rg11b10ufloat` will
* included in the test and fail if not appropriately filtered. If instead you were
* to use GPUTest then `rg11b10ufloat` would just be skipped as its never enabled.
* You could enable it manually but that spreads enabling to every test instead of being
* centralized in one place, here.
*/
export class AllFeaturesMaxLimitsGPUTest extends GPUTest {
public static override MakeSharedState(
recorder: TestCaseRecorder,
params: TestParams
): GPUTestSubcaseBatchState {
return new AllFeaturesMaxLimitsGPUTestSubcaseBatchState(recorder, params);
}
}