2

我正在尝试根据我看到的codepen对我的画布应用噪音效果,这又看起来与SO answer非常相似。

我想产生一个随机透明像素的“屏幕”,但我得到一个完全不透明的红色字段。我希望更熟悉画布或类型化数组的人可以告诉我我做错了什么,也许可以帮助我理解一些在起作用的技术。


我显着重构了 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的原始数据表示(一系列 8 位元素,它们依次反映每个像素的红色、绿色、蓝色和 alpha 值)在一个32 位数组,它允许我们将每个像素作为一个联合元组进行操作。我们得到一个每个像素一个元素而不是每个像素四个元素的数组。

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

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

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

有趣的是,我们不会将 写到buffer32画布上。相反,我们编写了imageData用于构造 的Uint32Array,这让我相信我们正在通过某种传递引用来改变 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

但它仍然是纯红色,纯黑色。没有任何底层画布数据通过应该不可见的像素显示。

令人困惑的是,除了红色或黑色之外,我找不到任何颜色。除了 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%

这是怎么回事?


Uint32Array编辑:根据MDN 关于以下内容的文章,ImageData.data在消除了和诡异的突变后的类似(坏)结果:

/**
 * 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)
}
4

1 回答 1

2

[TLDR]:

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


首先让我们解释一下TypedArrays: ArrayBuffers背后的“魔力” 。

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

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

您可以在同一个 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 分配的内存槽的二进制数据。

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

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

因此,您的代码正在利用 TypedArrays 只是视图对象这一事实,并且在底层 ArrayBuffer上具有其他视图将直接修改它。
它的作者选择使用 Uint32Array 是因为它是一种一次性设置完整像素(记住画布图像是 32 位)的方法。您可以将所需的工作量减少四倍。

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

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,因此您的计算机也是如此也就不足为奇了。


所以有了这一切,我希望你知道如何修复你的代码:要生成颜色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>

于 2019-06-06T11:07:50.087 回答