🏡 index : cold-air-networks/mirror-webgpu-cts.git

export const description = `
Tests for GPUCanvasContext.getCurrentTexture.
`;

import { SkipTestCase } from '../../../common/framework/fixture.js';
import { makeTestGroup } from '../../../common/framework/test_group.js';
import { timeout } from '../../../common/util/timeout.js';
import { assert, unreachable } from '../../../common/util/util.js';
import { AllFeaturesMaxLimitsGPUTest, GPUTest } from '../../gpu_test.js';
import { kAllCanvasTypes, createCanvas, CanvasType } from '../../util/create_elements.js';

const kFormat = 'bgra8unorm';

function expectSingleColorInCanvas(
  t: GPUTest,
  canvas: HTMLCanvasElement | OffscreenCanvas,
  expectedColor: number[],
  tolerance = 0.01
) {
  const { width, height } = canvas;
  const copy = new OffscreenCanvas(width, height);
  const copyCtx = copy.getContext('2d')!;
  copyCtx.drawImage(canvas, 0, 0);
  const imageData = copyCtx.getImageData(0, 0, width, height);
  for (let i = 0; i < imageData.data.length; i += 4) {
    expectedColor.forEach((expected, ch) => {
      const actual = imageData.data[i + ch] / 255;
      const diff = Math.abs(actual - expected);
      t.expect(
        diff < tolerance,
        () =>
          `at ${i % width}x${
            i / width
          }, channel: ${ch}, expected: ${expected}, actual: ${actual}, <  ${tolerance}`
      );
    });
  }
}

class GPUContextTest extends AllFeaturesMaxLimitsGPUTest {
  initCanvasContext(
    canvasType: CanvasType = 'onscreen',
    usage: GPUTextureUsageFlags = 0
  ): GPUCanvasContext {
    const canvas = createCanvas(this, canvasType, 2, 2);
    if (canvasType === 'onscreen') {
      // To make sure onscreen canvas are visible
      const onscreencanvas = canvas as HTMLCanvasElement;
      onscreencanvas.style.position = 'fixed';
      onscreencanvas.style.top = '0';
      onscreencanvas.style.left = '0';
      // Set it to transparent so that if multiple canvas are created, they are still visible.
      onscreencanvas.style.opacity = '50%';
      document.body.appendChild(onscreencanvas);
      this.trackForCleanup({
        close() {
          document.body.removeChild(onscreencanvas);
        },
      });
    }
    const ctx = canvas.getContext('webgpu');
    assert(ctx instanceof GPUCanvasContext, 'Failed to get WebGPU context from canvas');

    ctx.configure({
      device: this.device,
      format: kFormat,
      usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC | usage,
    });

    return ctx;
  }

  expectTextureDestroyed(texture: GPUTexture, expectDestroyed = true) {
    this.expectValidationError(() => {
      // Try using the texture in a render pass. Because it's a canvas texture
      // it should have RENDER_ATTACHMENT usage.
      assert((texture.usage & GPUTextureUsage.RENDER_ATTACHMENT) !== 0);
      const encoder = this.device.createCommandEncoder();
      const pass = encoder.beginRenderPass({
        colorAttachments: [
          {
            view: texture.createView(),
            loadOp: 'clear',
            storeOp: 'store',
          },
        ],
      });
      pass.end();
      // Submitting should generate a validation error if the texture is destroyed.
      this.queue.submit([encoder.finish()]);
    }, expectDestroyed);
  }
}

export const g = makeTestGroup(GPUContextTest);

g.test('configured')
  .desc(
    `Checks that calling getCurrentTexture requires the context to be configured first, and
  that each call to configure causes getCurrentTexture to return a new texture.`
  )
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
  )
  .fn(t => {
    const canvas = createCanvas(t, t.params.canvasType, 2, 2);
    const ctx = canvas.getContext('webgpu');
    assert(ctx instanceof GPUCanvasContext, 'Failed to get WebGPU context from canvas');

    // Calling getCurrentTexture prior to configuration should throw an InvalidStateError exception.
    t.shouldThrow('InvalidStateError', () => {
      ctx.getCurrentTexture();
    });

    // Once the context has been configured getCurrentTexture can be called.
    ctx.configure({
      device: t.device,
      format: kFormat,
    });

    let prevTexture = ctx.getCurrentTexture();

    // Calling configure again with different values will change the texture returned.
    ctx.configure({
      device: t.device,
      format: 'bgra8unorm',
    });

    let currentTexture = ctx.getCurrentTexture();
    t.expect(prevTexture !== currentTexture);
    prevTexture = currentTexture;

    // Calling configure again with the same values will still change the texture returned.
    ctx.configure({
      device: t.device,
      format: 'bgra8unorm',
    });

    currentTexture = ctx.getCurrentTexture();
    t.expect(prevTexture !== currentTexture);
    prevTexture = currentTexture;

    // Calling getCurrentTexture after calling unconfigure should throw an InvalidStateError exception.
    ctx.unconfigure();

    t.shouldThrow('InvalidStateError', () => {
      ctx.getCurrentTexture();
    });
  });

g.test('single_frames')
  .desc(`Checks that the value of getCurrentTexture is consistent within a single frame.`)
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
  )
  .fn(t => {
    const ctx = t.initCanvasContext(t.params.canvasType);
    const frameTexture = ctx.getCurrentTexture();

    // Calling getCurrentTexture a second time returns the same texture.
    t.expect(frameTexture === ctx.getCurrentTexture());

    const encoder = t.device.createCommandEncoder();
    const pass = encoder.beginRenderPass({
      colorAttachments: [
        {
          view: frameTexture.createView(),
          clearValue: [1.0, 0.0, 0.0, 1.0],
          loadOp: 'clear',
          storeOp: 'store',
        },
      ],
    });
    pass.end();
    t.device.queue.submit([encoder.finish()]);

    // Calling getCurrentTexture after performing some work on the texture returns the same texture.
    t.expect(frameTexture === ctx.getCurrentTexture());

    // Ensure that getCurrentTexture does not clear the texture.
    t.expectSingleColor(frameTexture, frameTexture.format, {
      size: [frameTexture.width, frameTexture.height, 1],
      exp: { R: 1, G: 0, B: 0, A: 1 },
    });

    frameTexture.destroy();

    // Calling getCurrentTexture after destroying the texture still returns the same texture.
    t.expect(frameTexture === ctx.getCurrentTexture());
  });

g.test('multiple_frames')
  .desc(`Checks that the value of getCurrentTexture differs across multiple frames.`)
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
      .beginSubcases()
      .combine('clearTexture', [true, false])
  )
  .beforeAllSubcases(t => {
    const { canvasType } = t.params;
    t.skipIf(
      canvasType === 'offscreen' && typeof OffscreenCanvas === 'undefined',
      'OffscreenCanvas does not exist in this environment'
    );
    t.skipIf(
      canvasType === 'offscreen' && !('transferToImageBitmap' in OffscreenCanvas.prototype),
      'transferToImageBitmap not supported'
    );
  })
  .fn(t => {
    const { canvasType, clearTexture } = t.params;

    return new Promise(resolve => {
      const ctx = t.initCanvasContext(canvasType);
      let prevTexture: GPUTexture | undefined;
      let frameCount = 0;

      function frameCheck() {
        const currentTexture = ctx.getCurrentTexture();

        if (prevTexture) {
          // Ensure that each frame a new texture object is returned.
          t.expect(currentTexture !== prevTexture);

          // Ensure that the texture's initial contents are transparent black.
          t.expectSingleColor(currentTexture, currentTexture.format, {
            size: [currentTexture.width, currentTexture.height, 1],
            exp: { R: 0, G: 0, B: 0, A: 0 },
          });
        }

        if (clearTexture) {
          // Fill the texture with a non-zero color, to test that texture
          // contents don't carry over from frame to frame.
          const encoder = t.device.createCommandEncoder();
          const pass = encoder.beginRenderPass({
            colorAttachments: [
              {
                view: currentTexture.createView(),
                clearValue: [1.0, 0.0, 0.0, 1.0],
                loadOp: 'clear',
                storeOp: 'store',
              },
            ],
          });
          pass.end();
          t.device.queue.submit([encoder.finish()]);
        }

        prevTexture = currentTexture;

        if (frameCount++ < 5) {
          // Which method will be used to begin a new "frame"?
          switch (canvasType) {
            case 'onscreen':
              requestAnimationFrame(frameCheck);
              break;
            case 'offscreen': {
              (ctx.canvas as OffscreenCanvas).transferToImageBitmap();
              frameCheck();
              break;
            }
            default:
              unreachable();
          }
        } else {
          resolve();
        }
      }

      // Render the first frame immediately. The rest will be triggered recursively.
      frameCheck();
    });
  });

g.test('resize')
  .desc(`Checks the value of getCurrentTexture differs when the canvas is resized.`)
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
  )
  .fn(t => {
    const ctx = t.initCanvasContext(t.params.canvasType);
    let prevTexture = ctx.getCurrentTexture();

    // Trigger a resize by changing the width.
    ctx.canvas.width = 4;

    t.expectTextureDestroyed(prevTexture);

    // When the canvas resizes the texture returned by getCurrentTexture should immediately begin
    // returning a new texture matching the update dimensions.
    let currentTexture = ctx.getCurrentTexture();
    t.expect(prevTexture !== currentTexture);
    t.expect(currentTexture.width === ctx.canvas.width);
    t.expect(currentTexture.height === ctx.canvas.height);

    // The width and height of the previous texture should remain unchanged.
    t.expect(prevTexture.width === 2);
    t.expect(prevTexture.height === 2);
    prevTexture = currentTexture;

    // Ensure that texture contents are transparent black.
    t.expectSingleColor(currentTexture, currentTexture.format, {
      size: [currentTexture.width, currentTexture.height, 1],
      exp: { R: 0, G: 0, B: 0, A: 0 },
    });

    // Trigger a resize by changing the height.
    ctx.canvas.height = 4;

    // Check to ensure the texture is resized again.
    currentTexture = ctx.getCurrentTexture();
    t.expect(prevTexture !== currentTexture);
    t.expect(currentTexture.width === ctx.canvas.width);
    t.expect(currentTexture.height === ctx.canvas.height);
    t.expect(prevTexture.width === 4);
    t.expect(prevTexture.height === 2);

    // Ensure that texture contents are transparent black.
    t.expectSingleColor(currentTexture, currentTexture.format, {
      size: [currentTexture.width, currentTexture.height, 1],
      exp: { R: 0, G: 0, B: 0, A: 0 },
    });

    // HTMLCanvasElement behaves differently than OffscreenCanvas
    if (t.params.canvasType === 'onscreen') {
      // Ensure canvas goes back to defaults when set to negative numbers.
      ctx.canvas.width = -1;
      currentTexture = ctx.getCurrentTexture();
      t.expect(currentTexture.width === 300);
      t.expect(currentTexture.height === 4);

      ctx.canvas.height = -1;
      currentTexture = ctx.getCurrentTexture();
      t.expect(currentTexture.width === 300);
      t.expect(currentTexture.height === 150);

      // Setting the canvas width and height values to their current values should
      // still trigger a change in the texture.
      prevTexture = ctx.getCurrentTexture();
      const { width, height } = ctx.canvas;
      ctx.canvas.width = width;
      ctx.canvas.height = height;

      t.expectTextureDestroyed(prevTexture);

      currentTexture = ctx.getCurrentTexture();
      t.expect(prevTexture !== currentTexture);
    }
  });

g.test('expiry')
  .desc(
    `
Test automatic WebGPU canvas texture expiry on all canvas types with the following requirements:
- getCurrentTexture returns the same texture object until the next task:
  - after previous frame update the rendering
  - before current frame update the rendering
  - in a microtask off the current frame task
- getCurrentTexture returns a new texture object and the old texture object becomes invalid
  as soon as possible after HTML update the rendering.

TODO: test more canvas types, and ways to update the rendering
- if on a different thread, expiry happens when the worker updates its rendering (worker "rPAF") OR transferToImageBitmap is called
- [draw, transferControlToOffscreen, then canvas is displayed] on either {main thread, or transferred to worker}
- [draw, canvas is displayed, then transferControlToOffscreen] on either {main thread, or transferred to worker}
- reftests for the above 2 (what gets displayed when the canvas is displayed)
- with canvas element added to DOM or not (applies to other canvas tests as well)
  - canvas is added to DOM after being rendered
  - canvas is already in DOM but becomes visible after being rendered
  `
  )
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
      .combine('prevFrameCallsite', ['runInNewCanvasFrame', 'requestAnimationFrame'] as const)
      .combine('getCurrentTextureAgain', [true, false] as const)
  )
  .beforeAllSubcases(t => {
    if (
      t.params.prevFrameCallsite === 'requestAnimationFrame' &&
      typeof requestAnimationFrame === 'undefined'
    ) {
      throw new SkipTestCase('requestAnimationFrame not available');
    }
  })
  .fn(t => {
    const { canvasType, prevFrameCallsite, getCurrentTextureAgain } = t.params;
    const ctx = t.initCanvasContext(t.params.canvasType);
    // Create a bindGroupLayout to test invalid texture view usage later.
    const bgl = t.device.createBindGroupLayout({
      entries: [
        {
          binding: 0,
          visibility: GPUShaderStage.COMPUTE,
          texture: {},
        },
      ],
    });

    // The fn is called immediately after previous frame updating the rendering.
    // Polyfill by calling the callback by setTimeout, in the requestAnimationFrame callback (for onscreen canvas)
    // or after transferToImageBitmap (for offscreen canvas).
    function runInNewCanvasFrame(fn: () => void) {
      switch (canvasType) {
        case 'onscreen':
          requestAnimationFrame(() => timeout(fn));
          break;
        case 'offscreen':
          // for offscreen canvas, after calling transferToImageBitmap, we are in a new frame immediately
          (ctx.canvas as OffscreenCanvas).transferToImageBitmap();
          fn();
          break;
        default:
          unreachable();
      }
    }

    function checkGetCurrentTexture() {
      // Call getCurrentTexture on previous frame.
      const prevTexture = ctx.getCurrentTexture();

      // Call getCurrentTexture immediately after the frame, the texture object should stay the same.
      queueMicrotask(() => {
        if (getCurrentTextureAgain) {
          t.expect(prevTexture === ctx.getCurrentTexture());
        }

        // Call getCurrentTexture in a new frame.
        // It should expire the previous texture object return a new texture object by the next frame by then.
        // Call runInNewCanvasFrame in the micro task to make sure the new frame run after the getCurrentTexture in the micro task for offscreen canvas.
        runInNewCanvasFrame(() => {
          if (getCurrentTextureAgain) {
            t.expect(prevTexture !== ctx.getCurrentTexture());
          }

          // Event when prevTexture expired, createView should still succeed anyway.
          const prevTextureView = prevTexture.createView();
          // Using the invalid view should fail if it expires.
          t.expectValidationError(() => {
            t.device.createBindGroup({
              layout: bgl,
              entries: [{ binding: 0, resource: prevTextureView }],
            });
          });
        });
      });
    }

    switch (prevFrameCallsite) {
      case 'runInNewCanvasFrame':
        runInNewCanvasFrame(checkGetCurrentTexture);
        break;
      case 'requestAnimationFrame':
        requestAnimationFrame(checkGetCurrentTexture);
        break;
      default:
        break;
    }
  });

g.test('compatibility')
  .desc(
    `
Test that the texture returned from getCurrentTexture has textureBindingViewDimension
and that it's undefined in core and '2d' in compatibility mode.
  `
  )
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
  )
  .fn(t => {
    const { canvasType } = t.params;
    const ctx = t.initCanvasContext(canvasType);
    const texture = ctx.getCurrentTexture();
    t.expect(() => 'textureBindingViewDimension' in texture);

    const expected = t.isCompatibility ? '2d' : undefined;
    t.expect(texture.textureBindingViewDimension === expected);
  });

g.test('usage_as_color_attachment_and_resolve_target')
  .desc(
    `
Test that the texture returned from getCurrentTexture can be used as a color attachment.
  `
  )
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
      .combine('withView', [true, false])
      .combine('asResolveTarget', [true, false])
  )
  .fn(t => {
    const { canvasType, withView, asResolveTarget } = t.params;
    const ctx = t.initCanvasContext(canvasType);
    const texture = ctx.getCurrentTexture();

    const clearValue = [1, 0.5, 0, 1];
    const encoder = t.device.createCommandEncoder();

    if (asResolveTarget) {
      const srcTexture = t.createTextureTracked({
        size: [texture.width, texture.height, 1],
        format: texture.format,
        usage: GPUTextureUsage.RENDER_ATTACHMENT,
        sampleCount: 4,
      });
      const pass = encoder.beginRenderPass({
        colorAttachments: [
          {
            view: withView ? srcTexture.createView() : srcTexture,
            resolveTarget: withView ? texture.createView() : texture,
            loadOp: 'clear',
            storeOp: 'store',
            clearValue,
          },
        ],
      });
      pass.end();
    } else {
      const pass = encoder.beginRenderPass({
        colorAttachments: [
          {
            view: withView ? texture.createView() : texture,
            loadOp: 'clear',
            storeOp: 'store',
            clearValue,
          },
        ],
      });
      pass.end();
    }
    t.device.queue.submit([encoder.finish()]);
    expectSingleColorInCanvas(t, ctx.canvas, clearValue);
  });

g.test('usage_as_texture_binding')
  .params(u =>
    u //
      .combine('canvasType', kAllCanvasTypes)
      .combine('withView', [true, false])
  )
  .fn(t => {
    const { canvasType, withView } = t.params;
    const ctx = t.initCanvasContext(canvasType, GPUTextureUsage.TEXTURE_BINDING);
    const texture = ctx.getCurrentTexture();

    const clearValue = [1, 1, 0, 1];
    const encoder = t.device.createCommandEncoder();

    // clear canvas to solid color.
    {
      const pass = encoder.beginRenderPass({
        colorAttachments: [
          {
            view: texture.createView(),
            loadOp: 'clear',
            storeOp: 'store',
            clearValue,
          },
        ],
      });
      pass.end();
    }

    // Render canvas to texture
    {
      const module = t.device.createShaderModule({
        code: `
          @vertex
          fn vs(@builtin(vertex_index) index : u32) -> @builtin(position) vec4f {
            let pos = array(
              vec2f(-1, -1),
              vec2f(-1,  3),
              vec2f( 3, -1),
            );
            return vec4f(pos[index], 0, 1);
          }

          @group(0) @binding(0) var tex: texture_2d<f32>;

          @fragment
          fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
            return textureLoad(tex, vec2u(pos.xy), 0);
          }
        `,
      });

      const pipeline = t.device.createRenderPipeline({
        layout: 'auto',
        vertex: { module },
        fragment: { module, targets: [{ format: texture.format }] },
      });

      const bindGroup = t.device.createBindGroup({
        layout: pipeline.getBindGroupLayout(0),
        entries: [
          {
            binding: 0,
            resource: withView ? texture.createView() : texture,
          },
        ],
      });

      const dstTexture = t.createTextureTracked({
        size: [texture.width, texture.height, 1],
        format: texture.format,
        usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
      });

      const pass = encoder.beginRenderPass({
        colorAttachments: [
          {
            view: dstTexture.createView(),
            loadOp: 'clear',
            storeOp: 'store',
          },
        ],
      });
      pass.setPipeline(pipeline);
      pass.setBindGroup(0, bindGroup);
      pass.draw(3);
      pass.end();
      t.device.queue.submit([encoder.finish()]);

      t.expectSingleColor(dstTexture, dstTexture.format, {
        size: [dstTexture.width, dstTexture.height, 1],
        exp: {
          R: clearValue[0],
          G: clearValue[1],
          B: clearValue[2],
          A: clearValue[3],
        },
      });
    }
  });