为什么我的 canvas 噪声函数总是只显示红色?

Why does my canvas noise function always only appear red?

我正在尝试根据 codepen I saw, which in turn appears to be very similar to an SO answer.

对我的 canvas 应用噪音效果

我想生成一个 "screen" 的随机透明像素,但我得到的是一个完全不透明的红色区域。我希望更熟悉 canvas 或类型数组的人可以告诉我我做错了什么,也许可以帮助我理解一些技巧。


我对 codepen 代码进行了重大重构,因为(目前)我不关心动画噪声:

/**
 * apply a "noise filter" to a rectangular region of the canvas
 * @param {Canvas2DContext} ctx - the context to draw on
 * @param {Number} x - the x-coordinate of the top-left corner of the region to noisify
 * @param {Number} y - the y-coordinate of the top-left corner of the region to noisify
 * @param {Number} width - how wide, in canvas units, the noisy region should be
 * @param {Number} height - how tall, in canvas units, the noisy region should be
 * @effect draws directly to the canvas
 */
function drawNoise( ctx, x, y, width, height ) {
    let imageData = ctx.createImageData(width, height)
    let buffer32 = new Uint32Array(imageData.data.buffer)

    for (let i = 0, len = buffer32.length; i < len; i++) {
        buffer32[i] = Math.random() < 0.5
            ? 0x00000088 // "noise" pixel
            : 0x00000000 // non-noise pixel
    }

    ctx.putImageData(imageData, x, y)
}

据我所知,发生的事情的核心是我们包装 ImageData 的原始数据表示(一系列反映红色、绿色、蓝色和 alpha 的 8 位元素每个像素的值,串联)在一个 32 位数组中,这允许我们将每个像素作为一个联合元组进行操作。我们得到一个每个像素一个元素而不是每个像素四个元素的数组。

然后,我们遍历该数组中的元素,根据我们的噪声逻辑将 RGBA 值写入每个元素(即每个像素)。这里的噪波逻辑非常简单:每个像素都有约 50% 的机会成为 "noise" 像素。

噪声像素被分配了 32 位值 0x00000088,这(由于数组提供的 32 位分块)相当于 rgba(0, 0, 0, 0.5),即黑色,50% 不透明度。

非噪声像素被赋予 32 位值 0x00000000,即黑色 0% 不透明度,即完全透明。

有趣的是,我们没有将 buffer32 写入 canvas。相反,我们编写了用于构造 Uint32ArrayimageData,这让我相信我们正在通过某种传递引用来改变 imageData 对象;我不清楚这是为什么。我知道值和引用传递在 JS 中通常是如何工作的(标量按值传递,对象按引用传递),但在非类型化数组世界中,传递给数组构造函数的值只决定数组的长度。这显然不是这里发生的事情。


如前所述,我得到的不是 50% 或 100% 透明的黑色像素区域,而是全实心像素区域,全是红色。我不仅不希望看到红色,而且随机颜色分配的证据为零:每个像素都是纯红色。

通过使用这两个十六进制值,我发现这会产生红色在黑色上的散射,具有正确的分布类型:

buffer32[i] = Math.random() < 0.5
    ? 0xff0000ff // <-- I'd assume this is solid red
    : 0xff000000 // <-- I'd assume this is invisible red

但它仍然是纯红色,纯黑色。 None 的基础 canvas 数据通过本应不可见的像素显示。

令人困惑的是,除了红色或黑色,我无法获得任何其他颜色。除了 100% 不透明之外,我也无法获得任何透明度。为了说明断开连接,我删除了随机元素并尝试将这九个值中的每一个写入每个像素以查看会发生什么:

buffer32[i] = 0xRrGgBbAa
                         // EXPECTED   // ACTUAL
buffer32[i] = 0xff0000ff // red 100%   // red 100%
buffer32[i] = 0x00ff00ff // green 100% // red 100%
buffer32[i] = 0x0000ffff // blue 100%  // red 100%
buffer32[i] = 0xff000088 // red 50%    // blood red; could be red on black at 50%
buffer32[i] = 0x00ff0088 // green 50%  // red 100%
buffer32[i] = 0x0000ff88 // blue 50%   // red 100%
buffer32[i] = 0xff000000 // red 0%     // black 100%
buffer32[i] = 0x00ff0000 // green 0%   // red 100%
buffer32[i] = 0x0000ff00 // blue 0%    // red 100%

怎么回事?


编辑:根据 MDN article on ImageData.data:

,在取消 Uint32Array 和诡异的突变后,结果相似(坏)
/**
 * fails in exactly the same way
 */
function drawNoise( ctx, x, y, width, height ) {
    let imageData = ctx.createImageData(width, height)

    for (let i = 0, len = imageData.data.length; i < len; i += 4) {
        imageData.data[i + 0] = 0
        imageData.data[i + 1] = 0
        imageData.data[i + 2] = 0
        imageData.data[i + 3] = Math.random() < 0.5 ? 255 : 0
    }

    ctx.putImageData(imageData, x, y)
}

[TLDR]:

您的硬件字节顺序设计为 LittleEndian,因此正确的十六进制格式是 0xAABBGGRR,而不是 0xRRGGBBAA


首先解释一下TypedArrays背后的“魔法”:ArrayBuffers.

ArrayBuffer 是一个非常特殊的对象,它直接链接到设备的内存。 ArrayBuffer interface 本身对我们来说没有太多的功能,但是当你创建一个时,你实际上在内存中分配了它的 length 用于你自己的脚本。也就是说,js 引擎不会像处理普通 JS 对象那样处理重新分配它、将它移动到其他地方、将它分块以及所有这些缓慢的操作。
因此,这使其成为处理二进制数据最快的对象之一。

但是,如前所述,它的接口本身就非常有限。我们无法直接从 ArrayBuffer 访问数据,为此我们必须使用 view 对象,它不会复制数据,但实际上只是提供一种访问方式直接。

您可以对同一个 ArrayBuffer 有不同的视图,但所使用的数据将始终只是 ArrayBuffer 中的一个,如果您确实从一个视图编辑 ArrayBuffer,那么它将从另一个视图可见:

const buffer = new ArrayBuffer(4);
const view1 = new Uint8Array(buffer);
const view2 = new Uint8Array(buffer);

console.log('view1', ...view1); // [0,0,0,0]
console.log('view2', ...view2); // [0,0,0,0]

// we modify only view1
view1[2] = 125;

console.log('view1', ...view1); // [0,0,125,0]
console.log('view2', ...view2); // [0,0,125,0]

有不同种类的视图对象,每一种都会提供不同的方式来表示分配给ArrayBuffer分配的内存槽的二进制数据。

TypedArrays 像 Uint8Array、Float32Array 等是 ArrayLike 接口,它提供了一种简单的方法来将数据作为数组进行操作,以它们自己的格式(8 位、Float32 等)表示数据。
DataView interface 允许更开放的操作,例如从通常无效的边界读取不同格式,但是,它是以性能为代价的。

ImageData interface 本身使用 ArrayBuffer 来存储其像素数据。默认情况下,它在此数据上公开一个 Uint8ClampedArray view。也就是说,一个 ArrayLike 对象,每个 32 位像素表示为从 0 到 255 的每个通道的值,依次为红色、绿色、蓝色和 Alpha。

所以你的代码利用了 TypedArrays 只是 view 对象这一事实,并且在底层 ArrayBuffer 上有另一个 view 将直接修改。
它的作者选择使用 Uint32Array,因为它是一种在单次拍摄中设置完整像素(记住 canvas 图像是 32 位)的方法。您可以将所需的工作量减少四倍。

但是,这样做,您将开始处理 32 位值。这可能会有点问题,因为现在 endianness 很重要。
Uint8Array [0x00, 0x11, 0x22, 0x33] 在 BigEndian 系统中将表示为 32 位值 0x00112233,但在 LittleEndian 系统中表示为 0x33221100

const buff = new ArrayBuffer(4);
const uint8 = new Uint8Array(buff);
const uint32 = new Uint32Array(buff);

uint8[0] = 0x00;
uint8[1] = 0x11;
uint8[2] = 0x22;
uint8[3] = 0x33;

const hex32 = uint32[0].toString(16);
console.log(hex32, hex32 === "33221100" ? 'LE' : 'BE');

请注意,大多数个人硬件都是 LittleEndian,因此如果您的计算机也是 LittleEndian 也就不足为奇了。


综上所述,我希望您知道如何修复您的代码:要生成颜色 rgba(0,0,0,.5),您需要设置 Uint32 值 0x80000000

drawNoise(canvas.getContext('2d'), 0, 0, 300, 150);

function drawNoise(ctx, x, y, width, height) {
  const imageData = ctx.createImageData(width, height)
  const buffer32 = new Uint32Array(imageData.data.buffer)

  const LE = isLittleEndian();
                  // 0xAABBRRGG : 0xRRGGBBAA;
  const black = LE ? 0x80000000 : 0x00000080;
  const blue  = LE ? 0xFFFF0000 : 0x0000FFFF;

  for (let i = 0, len = buffer32.length; i < len; i++) {
    buffer32[i] = Math.random() < 0.5

      ? black
      : blue
  }

  ctx.putImageData(imageData, x, y)
}
function isLittleEndian() {
  const uint8 = new Uint8Array(8);
  const uint32 = new Uint32Array(uint8.buffer);
  uint8[0] = 255;
  return uint32[0] === 0XFF;
}
<canvas id="canvas"></canvas>