blob: 42da982740b9395e04d378ba18160deba9da6b22 [file] [edit]
<script>
if (window.testRunner) { testRunner.waitUntilDone(); testRunner.dumpAsText(); }
// A 6x1 16-bit RGBA PNG, sRGB, no ICC profile. Its first three texels carry data in RGB under alpha 0.
const alphaPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAYAAAABEAYAAACtWQOzAAAAAXNSR0IArs4c6QAAAClJREFUeNpj+P9fTIwBDKysQOSJEykpRkYMDP//NzQ4ODQ0/IcCBigAAI3gEdFqsXjzAAAAAElFTkSuQmCC';
// The 16-bit values above, narrowed to 8 bits.
const sourceTexels = [
[255, 22, 0, 0],
[0, 58, 0, 0],
[200, 100, 50, 0],
[255, 128, 64, 128],
[255, 255, 255, 255],
[0, 0, 0, 0],
];
const width = sourceTexels.length;
// A 4x4 16-bit RGBA PNG whose texels encode their own column in R and row in G, so a copy which
// crops, flips, resizes or reorients wrongly yields a texel naming the position it came from. Alpha
// is 0 throughout, so these cases also fail if anything premultiplies on the way through.
const geometryPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAEEAYAAAD5YUI9AAAAAXNSR0IArs4c6QAAADdJREFUeNolykENADAMw8BAKLRCGoRAM6RVsj/3ceZ6L9nVVkEzs+ugrYLe0Dpoq6A3gIO2CvoB5wssAULpxwYAAAAASUVORK5CYII=';
// The same image carrying an eXIf chunk with Orientation = 6, which is a 90 degree clockwise turn.
const geometryExifPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAEEAYAAAD5YUI9AAAAAXNSR0IArs4c6QAAABplWElmTU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAADWZ0tpAAAAN0lEQVR42iXKQQ0AMAzDwEAotEIahEAzpFWyP/dx5nov2dVWQTOz66Ctgt7QOmiroDeAg7YK+gHnCywBQunHBgAAAABJRU5ErkJggg==';
const geometrySize = 4;
function geometryTexel(x, y)
{
return [x * 64 + 16, y * 64 + 16, 128, 0];
}
function premultiplied(texel)
{
const alpha = texel[3];
return [Math.round(texel[0] * alpha / 255), Math.round(texel[1] * alpha / 255), Math.round(texel[2] * alpha / 255), alpha];
}
function log(message)
{
document.getElementById('result').textContent += message + '\n';
}
// How a WebGPU engine reaches copyExternalImageToTexture: encoded bytes in a Blob.
function pngBlob(base64)
{
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; ++i)
bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: 'image/png' });
}
async function copiedTexels(device, source, dstPremultiplied, copyWidth = width, copyHeight = 1)
{
const texture = device.createTexture({
size: [copyWidth, copyHeight],
format: 'rgba8unorm',
usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.COPY_SRC | GPUTextureUsage.RENDER_ATTACHMENT,
});
device.queue.copyExternalImageToTexture({ source }, { texture, premultipliedAlpha: dstPremultiplied }, [copyWidth, copyHeight]);
// copyTextureToBuffer wants a 256-byte aligned row, which is more than any row used here.
const bytesPerRow = 256;
const buffer = device.createBuffer({ size: bytesPerRow * copyHeight, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
const encoder = device.createCommandEncoder();
encoder.copyTextureToBuffer({ texture }, { buffer, bytesPerRow }, [copyWidth, copyHeight]);
device.queue.submit([encoder.finish()]);
await buffer.mapAsync(GPUMapMode.READ);
const texels = new Uint8Array(buffer.getMappedRange()).slice();
buffer.unmap();
// Released here so that running this test repeatedly in one process does not accumulate them.
buffer.destroy();
texture.destroy();
return { texels, bytesPerRow };
}
// ImageBitmaps hold an image buffer each, so they are closed once the checks have read them.
const openBitmaps = [];
function tracked(bitmap)
{
openBitmaps.push(bitmap);
return bitmap;
}
// sourceOfDestination(x) names the source texel which must land at destination x.
function check(label, texels, dstPremultiplied, copyWidth = width, sourceOfDestination = x => x)
{
// Only wide enough for the level that narrowing 16 bits to 8 costs.
const tolerance = 2;
for (let x = 0; x < copyWidth; ++x) {
const sourceTexel = sourceTexels[sourceOfDestination(x)];
const expected = dstPremultiplied ? premultiplied(sourceTexel) : sourceTexel;
const actual = Array.from(texels.subarray(x * 4, x * 4 + 4));
const withinTolerance = expected.every((value, i) => Math.abs(actual[i] - value) <= tolerance);
// Only a failure prints the texel, so the expected output does not depend on GPU rounding.
log(withinTolerance ? `PASS ${label} texel ${x}` : `FAIL ${label} texel ${x}: got ${actual.join(',')}, expected ${expected.join(',')}`);
}
}
// The copy converts between the two modes, so only destination.premultipliedAlpha decides the texels.
async function checkBothDestinations(device, label, source, copyWidth = width, sourceOfDestination = x => x)
{
check(`${label} into unpremultiplied`, (await copiedTexels(device, source, false, copyWidth)).texels, false, copyWidth, sourceOfDestination);
check(`${label} into premultiplied`, (await copiedTexels(device, source, true, copyWidth)).texels, true, copyWidth, sourceOfDestination);
}
// sourceOfDestination(x, y) names the source texel that must land at destination (x, y).
async function checkGeometry(device, label, source, copyWidth, copyHeight, sourceOfDestination)
{
const { texels, bytesPerRow } = await copiedTexels(device, source, false, copyWidth, copyHeight);
const tolerance = 2;
for (let y = 0; y < copyHeight; ++y) {
for (let x = 0; x < copyWidth; ++x) {
const [sourceX, sourceY] = sourceOfDestination(x, y);
const expected = geometryTexel(sourceX, sourceY);
const offset = y * bytesPerRow + x * 4;
const actual = Array.from(texels.subarray(offset, offset + 4));
const withinTolerance = expected.every((value, i) => Math.abs(actual[i] - value) <= tolerance);
log(withinTolerance ? `PASS ${label} at ${x},${y}`
: `FAIL ${label} at ${x},${y}: got ${actual.join(',')}, expected ${expected.join(',')} (source ${sourceX},${sourceY})`);
}
}
}
onload = async () => {
let device;
try {
const adapter = await navigator.gpu.requestAdapter();
device = await adapter.requestDevice();
// Silent on a passing run, so a copy that fails instead of returning wrong texels says why.
device.addEventListener('uncapturederror', event => log(`FAIL GPU error ${event.error.message}`));
const blob = pngBlob(alphaPngBase64);
// The case this was written for: compositing the decode loses the RGB of the alpha 0 texels.
await checkBothDestinations(device, 'blob, premultiplyAlpha none', tracked(await createImageBitmap(blob, { premultiplyAlpha: 'none' })));
// Asking to premultiply permits the lossy path, and texels 0 to 2 are then legitimately black.
check('blob, premultiplyAlpha premultiply into premultiplied',
(await copiedTexels(device, tracked(await createImageBitmap(blob, { premultiplyAlpha: 'premultiply' })), true)).texels, true);
// An ImageBitmap of an ImageBitmap copies bytes rather than compositing them.
const nested = tracked(await createImageBitmap(tracked(await createImageBitmap(blob, { premultiplyAlpha: 'none' })), { premultiplyAlpha: 'none' }));
await checkBothDestinations(device, 'bitmap of bitmap', nested);
// An ImageData is stored in the mode that was asked for, so it was always right.
const imageData = new ImageData(width, 1);
for (let x = 0; x < width; ++x)
imageData.data.set(sourceTexels[x], x * 4);
await checkBothDestinations(device, 'ImageData, premultiplyAlpha none', tracked(await createImageBitmap(imageData, { premultiplyAlpha: 'none' })));
// Asking for any geometry at all takes an ImageData through a second buffer and a draw
// rather than storing it directly, and the channels have to survive that too. The image is
// one row tall, so flipping it moves nothing and only the alpha mode is under test here.
await checkBothDestinations(device, 'ImageData, premultiplyAlpha none, flipY',
tracked(await createImageBitmap(imageData, { premultiplyAlpha: 'none', imageOrientation: 'flipY' })));
// A crop names a sub-rectangle of the same stored texels.
await checkBothDestinations(device, 'ImageData, premultiplyAlpha none, crop 1,0 4x1',
tracked(await createImageBitmap(imageData, 1, 0, 4, 1, { premultiplyAlpha: 'none' })), 4, x => 1 + x);
// resizeQuality pixelated is nearest neighbour, so an integer upscale is exact.
await checkBothDestinations(device, 'ImageData, premultiplyAlpha none, resize 2x pixelated',
tracked(await createImageBitmap(imageData, { premultiplyAlpha: 'none', resizeWidth: width * 2, resizeHeight: 2, resizeQuality: 'pixelated' })),
width * 2, x => x >> 1);
// Cropping, flipping, resizing and reorienting all have to preserve the unpremultiplied
// channels too, and they take a different path from the copy which resamples nothing.
const geometry = pngBlob(geometryPngBase64);
const size = geometrySize;
// Nothing resampled: the pixels go in directly.
await checkGeometry(device, 'whole image', tracked(await createImageBitmap(geometry, { premultiplyAlpha: 'none' })),
size, size, (x, y) => [x, y]);
// A crop is a 1:1 copy of a sub-rectangle, so it is exact.
await checkGeometry(device, 'crop 1,1 2x2', tracked(await createImageBitmap(geometry, 1, 1, 2, 2, { premultiplyAlpha: 'none' })),
2, 2, (x, y) => [1 + x, 1 + y]);
// flipY reverses the rows.
await checkGeometry(device, 'flipY', tracked(await createImageBitmap(geometry, { premultiplyAlpha: 'none', imageOrientation: 'flipY' })),
size, size, (x, y) => [x, size - 1 - y]);
// resizeQuality pixelated is nearest neighbour, so an integer upscale is exact: each source
// texel becomes a 2x2 block.
await checkGeometry(device, 'resize 2x pixelated',
tracked(await createImageBitmap(geometry, { premultiplyAlpha: 'none', resizeWidth: size * 2, resizeHeight: size * 2, resizeQuality: 'pixelated' })),
size * 2, size * 2, (x, y) => [x >> 1, y >> 1]);
// EXIF orientation 6 turns the image 90 degrees clockwise, so the top row becomes the right
// column. The image is square, so the oriented extent is unchanged.
await checkGeometry(device, 'EXIF orientation 6', tracked(await createImageBitmap(pngBlob(geometryExifPngBase64), { premultiplyAlpha: 'none' })),
size, size, (x, y) => [y, size - 1 - x]);
// Combinations, which compose in the order the source rectangle is interpreted in: the crop
// names a rectangle of the oriented image, and flipY is part of the orientation, so a cropped
// and flipped copy takes rows from the flipped image. Flipped row j is stored row size-1-j,
// so destination (x, y) is stored (1 + x, size - 1 - (1 + y)).
await checkGeometry(device, 'crop 1,1 2x2 and flipY',
tracked(await createImageBitmap(geometry, 1, 1, 2, 2, { premultiplyAlpha: 'none', imageOrientation: 'flipY' })),
2, 2, (x, y) => [1 + x, size - 2 - y]);
// Resizing scales the cropped rectangle, so each cropped texel becomes a 2x2 block.
await checkGeometry(device, 'crop 1,1 2x2 and resize 2x pixelated',
tracked(await createImageBitmap(geometry, 1, 1, 2, 2, { premultiplyAlpha: 'none', resizeWidth: 4, resizeHeight: 4, resizeQuality: 'pixelated' })),
4, 4, (x, y) => [1 + (x >> 1), 1 + (y >> 1)]);
// Flipping and scaling together: the block a destination texel falls in is flipped.
await checkGeometry(device, 'flipY and resize 2x pixelated',
tracked(await createImageBitmap(geometry, { premultiplyAlpha: 'none', imageOrientation: 'flipY', resizeWidth: size * 2, resizeHeight: size * 2, resizeQuality: 'pixelated' })),
size * 2, size * 2, (x, y) => [x >> 1, size - 1 - (y >> 1)]);
} catch (error) {
log(`FAIL threw ${error}`);
} finally {
// Ten iterations of this test share one process, so nothing may outlive a single run.
for (const bitmap of openBitmaps)
bitmap.close();
device?.destroy();
}
if (window.testRunner)
testRunner.notifyDone();
};
</script>
<p>Tests that createImageBitmap(premultiplyAlpha: "none") preserves an alpha channel that carries data rather than opacity, including when the copy crops, flips, resizes or reorients, so that GPUQueue.copyExternalImageToTexture can upload it.</p>
<pre id="result"></pre>