| import { assert, memcpy, TypedArrayBufferView } from '../common/util/util.js'; |
| |
| import { |
| ColorTextureFormat, |
| EncodableTextureFormat, |
| getBlockInfoForColorTextureFormat, |
| getBlockInfoForTextureFormat, |
| kEncodableTextureFormats, |
| resolvePerAspectFormat, |
| } from './format_info.js'; |
| import { GPUTest } from './gpu_test.js'; |
| import { checkElementsEqual } from './util/check_contents.js'; |
| import { align } from './util/math.js'; |
| import { physicalMipSizeFromTexture, virtualMipSize } from './util/texture/base.js'; |
| import { |
| bytesInACompleteRow, |
| getTextureCopyLayout, |
| LayoutOptions as TextureLayoutOptions, |
| } from './util/texture/layout.js'; |
| import { PerTexelComponent } 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 { createTextureFromTexelViews } from './util/texture.js'; |
| import { reifyOrigin3D } from './util/unions.js'; |
| |
| type PipelineType = '2d' | '2d-array' | '3d'; |
| |
| type ImageCopyTestResources = { |
| pipelineByPipelineType: Map<PipelineType, GPURenderPipeline>; |
| }; |
| |
| const s_deviceToResourcesMap = new WeakMap<GPUDevice, ImageCopyTestResources>(); |
| /** |
| * Gets a (cached) pipeline to render a texture to an rgba8unorm texture |
| */ |
| function getPipelineToRenderTextureToRGB8UnormTexture(device: GPUDevice, texture: GPUTexture) { |
| if (!s_deviceToResourcesMap.has(device)) { |
| s_deviceToResourcesMap.set(device, { |
| pipelineByPipelineType: new Map<PipelineType, GPURenderPipeline>(), |
| }); |
| } |
| |
| const { pipelineByPipelineType } = s_deviceToResourcesMap.get(device)!; |
| const pipelineType: PipelineType = |
| texture.dimension === '3d' ? '3d' : texture.depthOrArrayLayers > 1 ? '2d-array' : '2d'; |
| if (!pipelineByPipelineType.get(pipelineType)) { |
| const [textureType, coordCode] = |
| pipelineType === '3d' |
| ? [ |
| 'texture_3d', |
| 'vec3f(fsInput.texcoord, (f32(uni.baseArrayLayer) + 0.5) / f32(textureDimensions(ourTexture, 0).z))', |
| ] |
| : pipelineType === '2d' |
| ? ['texture_2d', 'fsInput.texcoord'] |
| : ['texture_2d_array', 'fsInput.texcoord, uni.baseArrayLayer']; |
| const code = ` |
| struct VSOutput { |
| @builtin(position) position: vec4f, |
| @location(0) texcoord: vec2f, |
| }; |
| |
| struct Uniforms { |
| baseArrayLayer: u32, |
| }; |
| |
| @vertex fn vs( |
| @builtin(vertex_index) vertexIndex : u32 |
| ) -> VSOutput { |
| let pos = array( |
| vec2f(-1, -1), |
| vec2f(-1, 3), |
| vec2f( 3, -1), |
| ); |
| |
| var vsOutput: VSOutput; |
| |
| let xy = pos[vertexIndex]; |
| |
| vsOutput.position = vec4f(xy, 0.0, 1.0); |
| vsOutput.texcoord = xy * vec2f(0.5, -0.5) + vec2f(0.5); |
| |
| return vsOutput; |
| } |
| |
| @group(0) @binding(0) var ourSampler: sampler; |
| @group(0) @binding(1) var ourTexture: ${textureType}<f32>; |
| @group(0) @binding(2) var<uniform> uni: Uniforms; |
| |
| @fragment fn fs(fsInput: VSOutput) -> @location(0) vec4f { |
| _ = uni; |
| return textureSample(ourTexture, ourSampler, ${coordCode}); |
| } |
| `; |
| const module = device.createShaderModule({ code }); |
| const pipeline = device.createRenderPipeline({ |
| label: `layer rendered for ${pipelineType}`, |
| layout: 'auto', |
| vertex: { |
| module, |
| entryPoint: 'vs', |
| }, |
| fragment: { |
| module, |
| entryPoint: 'fs', |
| targets: [{ format: 'rgba8unorm' }], |
| }, |
| }); |
| pipelineByPipelineType.set(pipelineType, pipeline); |
| } |
| const pipeline = pipelineByPipelineType.get(pipelineType)!; |
| return { pipelineType, pipeline }; |
| } |
| |
| type LinearCopyParameters = { |
| dataLayout: Required<GPUTexelCopyBufferLayout>; |
| origin: Required<GPUOrigin3DDict>; |
| data: Uint8Array; |
| }; |
| |
| /** |
| * Creates a 1 mip level texture with the contents of a TexelView. |
| */ |
| export function createTextureFromTexelView( |
| t: GPUTest, |
| texelView: TexelView, |
| desc: Omit<GPUTextureDescriptor, 'format'> |
| ): GPUTexture { |
| return createTextureFromTexelViews(t, [texelView], desc); |
| } |
| |
| export function createTextureFromTexelViewsMultipleMipmaps( |
| t: GPUTest, |
| texelViews: TexelView[], |
| desc: Omit<GPUTextureDescriptor, 'format'> |
| ): GPUTexture { |
| return createTextureFromTexelViews(t, texelViews, desc); |
| } |
| |
| export function expectTexelViewComparisonIsOkInTexture( |
| t: GPUTest, |
| src: GPUTexelCopyTextureInfo, |
| exp: TexelView, |
| size: GPUExtent3D, |
| comparisonOptions = { |
| maxIntDiff: 0, |
| maxDiffULPsForNormFormat: 1, |
| maxDiffULPsForFloatFormat: 1, |
| } as TexelCompareOptions |
| ): void { |
| t.eventualExpectOK( |
| textureContentIsOKByT2B(t, src, size, { expTexelView: exp }, comparisonOptions) |
| ); |
| } |
| |
| export function expectSinglePixelComparisonsAreOkInTexture<E extends PixelExpectation>( |
| t: GPUTest, |
| src: GPUTexelCopyTextureInfo, |
| exp: PerPixelComparison<E>[], |
| comparisonOptions = { |
| maxIntDiff: 0, |
| maxDiffULPsForNormFormat: 1, |
| maxDiffULPsForFloatFormat: 1, |
| } as TexelCompareOptions |
| ): 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; |
| } |
| })(); |
| |
| t.eventualExpectOK( |
| textureContentIsOKByT2B( |
| t, |
| { ...src, origin: reifyOrigin3D(lowerCorner) }, |
| size, |
| { expTexelView }, |
| comparisonOptions, |
| coordsF |
| ) |
| ); |
| } |
| |
| export function expectTexturesToMatchByRendering( |
| t: GPUTest, |
| actualTexture: GPUTexture, |
| expectedTexture: GPUTexture, |
| mipLevel: number, |
| origin: Required<GPUOrigin3DDict>, |
| size: Required<GPUExtent3DDict> |
| ): void { |
| // Render every layer of both textures at mipLevel to an rgba8unorm texture |
| // that matches the size of the mipLevel. After each render, copy the |
| // result to a buffer and expect the results from both textures to match. |
| const { pipelineType, pipeline } = getPipelineToRenderTextureToRGB8UnormTexture( |
| t.device, |
| actualTexture |
| ); |
| const readbackPromisesPerTexturePerLayer = [actualTexture, expectedTexture].map( |
| (texture, ndx) => { |
| const attachmentSize = virtualMipSize( |
| actualTexture.dimension, |
| [texture.width, texture.height, 1], |
| mipLevel |
| ); |
| const attachment = t.createTextureTracked({ |
| label: `readback${ndx}`, |
| size: attachmentSize, |
| format: 'rgba8unorm', |
| usage: GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT, |
| }); |
| |
| const sampler = t.device.createSampler(); |
| |
| const numLayers = texture.depthOrArrayLayers; |
| const readbackPromisesPerLayer = []; |
| |
| const uniformBuffer = t.createBufferTracked({ |
| label: 'expectTexturesToMatchByRendering:uniformBuffer', |
| size: 4, |
| usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, |
| }); |
| |
| for (let layer = 0; layer < numLayers; ++layer) { |
| const viewDescriptor: GPUTextureViewDescriptor = { |
| baseMipLevel: mipLevel, |
| mipLevelCount: 1, |
| dimension: pipelineType, |
| }; |
| |
| const bindGroup = t.device.createBindGroup({ |
| layout: pipeline.getBindGroupLayout(0), |
| entries: [ |
| { binding: 0, resource: sampler }, |
| { |
| binding: 1, |
| resource: texture.createView(viewDescriptor), |
| }, |
| { |
| binding: 2, |
| resource: { buffer: uniformBuffer }, |
| }, |
| ], |
| }); |
| |
| t.device.queue.writeBuffer(uniformBuffer, 0, new Uint32Array([layer])); |
| |
| const encoder = t.device.createCommandEncoder({ |
| label: 'expectTexturesToMatchByRendering', |
| }); |
| const pass = encoder.beginRenderPass({ |
| colorAttachments: [ |
| { |
| view: attachment.createView(), |
| clearValue: [0.5, 0.5, 0.5, 0.5], |
| loadOp: 'clear', |
| storeOp: 'store', |
| }, |
| ], |
| }); |
| pass.setPipeline(pipeline); |
| pass.setBindGroup(0, bindGroup); |
| pass.draw(3); |
| pass.end(); |
| t.queue.submit([encoder.finish()]); |
| |
| const buffer = copyWholeTextureToNewBufferSimple(t, attachment, 0); |
| |
| readbackPromisesPerLayer.push( |
| t.readGPUBufferRangeTyped(buffer, { |
| type: Uint8Array, |
| typedLength: buffer.size, |
| }) |
| ); |
| } |
| return readbackPromisesPerLayer; |
| } |
| ); |
| |
| t.eventualAsyncExpectation(async niceStack => { |
| const readbacksPerTexturePerLayer = []; |
| |
| // Wait for all buffers to be ready |
| for (const readbackPromises of readbackPromisesPerTexturePerLayer) { |
| readbacksPerTexturePerLayer.push(await Promise.all(readbackPromises)); |
| } |
| |
| function arrayNotAllTheSameValue(arr: TypedArrayBufferView | number[], msg?: string) { |
| const first = arr[0]; |
| return arr.length <= 1 || arr.findIndex(v => v !== first) >= 0 |
| ? undefined |
| : Error(`array is entirely ${first} so likely nothing was tested: ${msg || ''}`); |
| } |
| |
| // Compare each layer of each texture as read from buffer. |
| const [actualReadbacksPerLayer, expectedReadbacksPerLayer] = readbacksPerTexturePerLayer; |
| for (let layer = 0; layer < actualReadbacksPerLayer.length; ++layer) { |
| const actualReadback = actualReadbacksPerLayer[layer]; |
| const expectedReadback = expectedReadbacksPerLayer[layer]; |
| const sameOk = |
| size.width === 0 || |
| size.height === 0 || |
| layer < origin.z || |
| layer >= origin.z + size.depthOrArrayLayers; |
| t.expectOK( |
| sameOk ? undefined : arrayNotAllTheSameValue(actualReadback.data, 'actualTexture') |
| ); |
| t.expectOK( |
| sameOk ? undefined : arrayNotAllTheSameValue(expectedReadback.data, 'expectedTexture') |
| ); |
| t.expectOK(checkElementsEqual(actualReadback.data, expectedReadback.data), { |
| mode: 'fail', |
| niceStack, |
| }); |
| actualReadback.cleanup(); |
| expectedReadback.cleanup(); |
| } |
| }); |
| } |
| |
| /** |
| * 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. |
| */ |
| export function expectSingleColorWithTolerance( |
| t: GPUTest, |
| src: GPUTexture, |
| format: GPUTextureFormat, |
| { |
| size, |
| exp, |
| dimension = '2d', |
| slice = 0, |
| layout, |
| maxFractionalDiff, |
| }: { |
| size: [number, number, number]; |
| exp: PerTexelComponent<number>; |
| dimension?: GPUTextureDimension; |
| slice?: number; |
| layout?: TextureLayoutOptions; |
| maxFractionalDiff?: number; |
| } |
| ): void { |
| assert(slice === 0 || dimension === '2d', 'texture slices are only implemented for 2d textures'); |
| |
| format = resolvePerAspectFormat(format, layout?.aspect); |
| const { 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, |
| ]; |
| |
| // Create a TexelView that returns exp for all texels. |
| const expTexelView = TexelView.fromTexelsAsColors(format as EncodableTextureFormat, () => exp); |
| const source: GPUTexelCopyTextureInfo = { |
| texture: src, |
| mipLevel: layout?.mipLevel ?? 0, |
| aspect: layout?.aspect ?? 'all', |
| origin: [0, 0, slice], |
| }; |
| const comparisonOptions = { |
| maxFractionalDiff: maxFractionalDiff ?? 0, |
| }; |
| t.eventualExpectOK( |
| textureContentIsOKByT2B(t, source, copySize, { expTexelView }, comparisonOptions) |
| ); |
| } |
| |
| export function copyWholeTextureToNewBufferSimple( |
| t: GPUTest, |
| texture: GPUTexture, |
| mipLevel: number |
| ) { |
| const { blockWidth, blockHeight, bytesPerBlock } = getBlockInfoForTextureFormat(texture.format); |
| const mipSize = physicalMipSizeFromTexture(texture, mipLevel); |
| assert(bytesPerBlock !== undefined); |
| |
| const blocksPerRow = mipSize[0] / blockWidth; |
| const blocksPerColumn = mipSize[1] / blockHeight; |
| |
| assert(blocksPerRow % 1 === 0); |
| assert(blocksPerColumn % 1 === 0); |
| |
| const bytesPerRow = align(blocksPerRow * bytesPerBlock, 256); |
| const byteLength = bytesPerRow * blocksPerColumn * mipSize[2]; |
| |
| return copyWholeTextureToNewBuffer( |
| t, |
| { texture, mipLevel }, |
| { |
| bytesPerBlock, |
| bytesPerRow, |
| rowsPerImage: blocksPerColumn, |
| byteLength, |
| } |
| ); |
| } |
| |
| export function copyWholeTextureToNewBuffer( |
| t: GPUTest, |
| { texture, mipLevel }: { texture: GPUTexture; mipLevel: number | undefined }, |
| resultDataLayout: { |
| bytesPerBlock: number; |
| byteLength: number; |
| bytesPerRow: number; |
| rowsPerImage: number; |
| } |
| ): GPUBuffer { |
| const { byteLength, bytesPerRow, rowsPerImage } = resultDataLayout; |
| const buffer = t.createBufferTracked({ |
| label: 'copyWholeTextureToNewBuffer:buffer', |
| size: align(byteLength, 4), // this is necessary because we need to copy and map data from this buffer |
| usage: GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST, |
| }); |
| |
| const mipSize = physicalMipSizeFromTexture(texture, mipLevel || 0); |
| const encoder = t.device.createCommandEncoder({ label: 'copyWholeTextureToNewBuffer' }); |
| encoder.copyTextureToBuffer( |
| { texture, mipLevel }, |
| { buffer, bytesPerRow, rowsPerImage }, |
| mipSize |
| ); |
| t.device.queue.submit([encoder.finish()]); |
| |
| return buffer; |
| } |
| |
| export function updateLinearTextureDataSubBox( |
| t: GPUTest, |
| format: ColorTextureFormat, |
| copySize: Required<GPUExtent3DDict>, |
| copyParams: { |
| dest: LinearCopyParameters; |
| src: LinearCopyParameters; |
| } |
| ): void { |
| const { src, dest } = copyParams; |
| const rowLength = bytesInACompleteRow(copySize.width, format); |
| for (const texel of iterateBlockRows(copySize, format)) { |
| const srcOffsetElements = getTexelOffsetInBytes(src.dataLayout, format, texel, src.origin); |
| const dstOffsetElements = getTexelOffsetInBytes(dest.dataLayout, format, texel, dest.origin); |
| memcpy( |
| { src: src.data, start: srcOffsetElements, length: rowLength }, |
| { dst: dest.data, start: dstOffsetElements } |
| ); |
| } |
| } |
| |
| /** Offset for a particular texel in the linear texture data */ |
| export function getTexelOffsetInBytes( |
| textureDataLayout: Required<GPUTexelCopyBufferLayout>, |
| format: ColorTextureFormat, |
| texel: Required<GPUOrigin3DDict>, |
| origin: Required<GPUOrigin3DDict> = { x: 0, y: 0, z: 0 } |
| ): number { |
| const { offset, bytesPerRow, rowsPerImage } = textureDataLayout; |
| const info = getBlockInfoForColorTextureFormat(format); |
| |
| assert(texel.x % info.blockWidth === 0); |
| assert(texel.y % info.blockHeight === 0); |
| assert(origin.x % info.blockWidth === 0); |
| assert(origin.y % info.blockHeight === 0); |
| |
| const bytesPerImage = rowsPerImage * bytesPerRow; |
| |
| return ( |
| offset + |
| (texel.z + origin.z) * bytesPerImage + |
| ((texel.y + origin.y) / info.blockHeight) * bytesPerRow + |
| ((texel.x + origin.x) / info.blockWidth) * info.bytesPerBlock |
| ); |
| } |
| |
| export function* iterateBlockRows( |
| size: Required<GPUExtent3DDict>, |
| format: ColorTextureFormat |
| ): Generator<Required<GPUOrigin3DDict>> { |
| if (size.width === 0 || size.height === 0 || size.depthOrArrayLayers === 0) { |
| // do not iterate anything for an empty region |
| return; |
| } |
| const info = getBlockInfoForTextureFormat(format); |
| assert(size.height % info.blockHeight === 0); |
| // Note: it's important that the order is in increasing memory address order. |
| for (let z = 0; z < size.depthOrArrayLayers; ++z) { |
| for (let y = 0; y < size.height; y += info.blockHeight) { |
| yield { |
| x: 0, |
| y, |
| z, |
| }; |
| } |
| } |
| } |