使用类型化数组加速 Canvas 像素操作

编辑: 查看关于 字节序 的部分。

类型化数组 可以显著提升 HTML5 2D Canvas 网页应用的像素操作性能。对于那些想要使用 HTML5 开发基于浏览器的游戏的开发者来说,这一点尤其重要。

这篇文章是由 Andrew J. Baker 客座撰写的。Andrew 是一位专业的软件工程师,目前在 Ibuildings UK 工作,他的时间被平均分配到前端和后端企业 Web 开发。他是 Freenode 上基于浏览器的游戏频道 #bbg 的主要成员,在 2011 年 9 月的第一个 HTML5 游戏会议上发表了演讲,并且是 Mozilla 的 WebFWD 创新加速器项目的一位侦察员。


避免使用 Canvas 上绘制图像和原语的更高级方法,我们将直接操作像素,使用 ImageData。

传统 8 位像素操作

以下示例演示了使用图像数据进行像素操作,以在 Canvas 上生成灰度莫尔纹。

JSFiddle 演示.

让我们逐步分析。

首先,我们从 DOM 获取到 Canvas 元素的引用,该元素的 id 属性为 canvas

var canvas = document.getElementById('canvas');

接下来的两行可能看起来像是微优化,事实上它们确实是。但考虑到在主循环中访问 Canvas 宽度和高度的次数,将 canvas.widthcanvas.height 的值分别复制到变量 canvasWidthcanvasHeight 中,可以对性能产生明显影响。

var canvasWidth  = canvas.width;
var canvasHeight = canvas.height;

现在我们需要获取 Canvas 的 2D 上下文的引用。

var ctx = canvas.getContext('2d');

有了 Canvas 的 2D 上下文的引用,我们现在就可以获取 Canvas 的图像数据的引用。注意,这里我们获取了整个 Canvas 的图像数据,但这并非总是必要的。

var imageData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);

再次,另一个看似无关紧要的微优化,获取对原始像素数据的引用,这也可以对性能产生明显影响。

var data = imageData.data;

现在是代码的主体部分。有两个循环,一个嵌套在另一个内部。外部循环遍历 y 轴,内部循环遍历 x 轴。

for (var y = 0; y < canvasHeight; ++y) {
    for (var x = 0; x < canvasWidth; ++x) {

我们以自上而下、从左到右的顺序将像素绘制到图像数据中。请记住,y 轴是反向的,所以原点 (0,0) 指的是 Canvas 的左上角。

变量 data 引用的 ImageData.data 属性是一个一维整数数组,每个元素的范围为 0..255。ImageData.data 以重复的序列排列,因此每个元素都对应于一个单独的通道。重复序列如下:

data[0]  = red channel of first pixel on first row
data[1]  = green channel of first pixel on first row
data[2]  = blue channel of first pixel on first row
data[3]  = alpha channel of first pixel on first row

data[4]  = red channel of second pixel on first row
data[5]  = green channel of second pixel on first row
data[6]  = blue channel of second pixel on first row
data[7]  = alpha channel of second pixel on first row

data[8]  = red channel of third pixel on first row
data[9]  = green channel of third pixel on first row
data[10] = blue channel of third pixel on first row
data[11] = alpha channel of third pixel on first row


...

在绘制像素之前,我们必须将 x 和 y 坐标转换为一个索引,代表一维数组中第一个通道的偏移量。

        var index = (y * canvasWidth + x) * 4;

我们将 y 坐标乘以 Canvas 的宽度,加上 x 坐标,然后乘以 4。我们必须乘以 4,因为每个像素有 4 个元素,每个通道一个。

现在我们计算像素的颜色。

为了生成莫尔纹,我们将 x 坐标乘以 y 坐标,然后将结果与十六进制 0xff(十进制 255)进行按位与运算,以确保值在 0..255 的范围内。

        var value = x * y & 0xff;

灰度颜色具有相同的红色、绿色和蓝色通道值。因此,我们为红色、绿色和蓝色通道分配相同的值。一维数组的顺序要求我们在索引处分配红色通道的值,在索引 + 1 处分配绿色通道的值,在索引 + 2 处分配蓝色通道的值。

        data[index]   = value;	// red
        data[++index] = value;	// green
        data[++index] = value;	// blue

这里我们递增索引,因为我们在每次迭代开始时使用内循环重新计算它。

我们需要考虑的最后一个通道是索引 + 3 处的 alpha 通道。为了确保绘制的像素是 100% 不透明的,我们将 alpha 通道设置为 255,并终止两个循环。

        data[++index] = 255;	// alpha
    }
}

为了使修改后的图像数据在 Canvas 中显示,我们必须将图像数据放在原点 (0,0) 位置。

ctx.putImageData(imageData, 0, 0);

注意,因为 dataimageData.data 的引用,所以我们不需要显式地重新赋值。

ImageData 对象

在撰写本文时,HTML5 规范仍处于不断变化之中。

HTML5 规范的早期版本这样声明 ImageData 对象:

interface ImageData {
    readonly attribute unsigned long width;
    readonly attribute unsigned long height;
    readonly attribute CanvasPixelArray data;
}

随着类型化数组的引入,数据属性的类型已从 CanvasPixelArray 更改为 Uint8ClampedArray,现在看起来像这样:

interface ImageData {
    readonly attribute unsigned long width;
    readonly attribute unsigned long height;
    readonly attribute Uint8ClampedArray data;
}

乍一看,这似乎并没有给我们带来任何大的改进,除了使用一种在 HTML5 规范的其他地方也使用的类型。

但是,我们现在将向您展示如何利用弃用 CanvasPixelArray 而支持 Uint8ClampedArray 带来的灵活性。

以前,我们被迫一次写入一个通道,将颜色值写入图像数据的一维数组。

利用类型化数组和 ArrayBuffer 和 ArrayBufferView 对象,我们可以一次写入一个像素,将颜色值写入图像数据数组!

更快的 32 位像素操作

以下示例复制了先前示例的功能,但使用无符号 32 位写入。

注意:如果您的浏览器没有使用 Uint8ClampedArray 作为 ImageData 对象的 data 属性的类型,则此示例将无法正常工作!

JSFiddle 演示.

第一个与原始示例的偏差始于一个名为 buf 的 ArrayBuffer 的实例化。

var buf = new ArrayBuffer(imageData.data.length);

此 ArrayBuffer 将用于临时存储图像数据的内容。

接下来我们创建两个 ArrayBuffer 视图。一个允许我们查看 buf 作为一个无符号 8 位值的 一维数组,另一个允许我们查看 buf 作为一个无符号 32 位值的 一维数组。

var buf8 = new Uint8ClampedArray(buf);
var data = new Uint32Array(buf);

不要被“视图”这个词所误导。buf8data 都可以 **读写**。有关 ArrayBufferView 的更多信息,请访问 MDN 网站。

下一个改变是内部循环的主体。我们不再需要在局部变量中计算索引,所以我们直接跳到计算用于填充红色、绿色和蓝色通道的值,就像我们之前所做的那样。

计算完成后,我们就可以使用一次赋值来绘制像素。红色、绿色和蓝色通道的值以及 alpha 通道都使用按位左移和按位或运算打包到一个整数中。

        data[y * canvasWidth + x] =
            (255   << 24) |	// alpha
            (value << 16) |	// blue
            (value <<  8) |	// green
             value;		// red
    }
}

因为我们现在处理的是无符号 32 位值,所以不需要将偏移量乘以 4。

在终止两个循环后,我们必须将 ArrayBuffer buf 的内容赋值给 imageData.data。我们使用 Uint8ClampedArray.set() 方法将 data 属性设置为 ArrayBuffer 的 Uint8ClampedArray 视图,方法是将 buf8 作为参数指定。

imageData.data.set(buf8);

最后,我们使用 putImageData() 将图像数据复制回 Canvas。

测试性能

我们已经告诉您,使用类型化数组进行像素操作速度更快。我们确实应该测试一下,这就是 这个 jsperf 测试 所做的。

在撰写本文时,32 位像素操作确实更快。

总结

您并不总是需要使用像素级操作来处理 Canvas,但当您需要时,请务必查看类型化数组,以获得潜在的性能提升。

编辑:字节序

正如评论中正确指出的,最初呈现的代码没有正确考虑执行 JavaScript 的处理器的 字节序

但是,下面的代码通过测试目标处理器的字节序,然后根据处理器是大端还是小端执行不同的主循环版本,来纠正了这种疏忽。

JSFiddle 演示.

针对此修改后的代码的 对应的 jsperf 测试 也是这样编写的,它显示出与原始 jsperf 测试几乎相同的结果。因此,我们的最终结论仍然相同。

非常感谢所有评论者和测试者。

关于 Paul Rouget

Paul 是一位 Firefox 开发者。

更多 Paul Rouget 的文章...


21 条评论

  1. Boris

    此技巧仅适用于小端硬件。在大端硬件上,32 位整数的高字节将是红色通道,而不是 alpha。所以,您为某些用户提高了性能,但代价是其他用户完全错误的行为。

    现在,您可以检测字节序并通过运行略微不同的代码来解决这个问题,但这会增加代码复杂性或降低性能,或者两者兼而有之。

    2011 年 12 月 1 日 下午 2:38

    1. Andrew J. Baker

      关于字节序,你说得很对。有一个额外的参数可以用于指定 setUint32() 是否需要小端字节序。

      http://www.khronos.org/registry/typedarray/specs/latest/#8

      2011 年 12 月 1 日 下午 3:00

      1. Boris

        是的,有。它的性能如何?

        2011 年 12 月 1 日 下午 7:12

    2. Joe

      确定吗?规范明确指出图像数据按 RGBA 顺序排列。

      2011 年 12 月 1 日 下午 3:26

      1. Boris

        这正是问题所在。图像数据始终为 RGBA。但是,由于字节移位不是针对物理字节,而是针对数值进行移位,因此字节移位生成的字节顺序取决于字节序。因此,表达式 255 << 24 始终为 32 位整数 0xff000000,在小端系统上用字节序列 [0, 0, 0, 0xff] 表示,在大端系统上用字节序列 [0xff, 0, 0, 0] 表示。因此,在小端系统上,它将为您提供一个不透明的黑色像素,而在大端系统上,它是一个透明的红色像素。

        2011 年 12 月 1 日 下午 3:31

        1. Ryan Badour

          Javascript 是一种解释型语言,这与低级 C 或 C++ 不同,谁说字节序被抽象掉了?您确定不是吗?

          2011 年 12 月 1 日 下午 4:53

          1. Boris

            是的。类型化数组规范非常清楚地说明了字节序在类型化数组中没有被抽象掉。我建议直接阅读规范,而不是猜测和希望。

            2011 年 12 月 2 日 上午 5:50

        2. barryvan

          那么,这是否有效地排除了在操作像素数据时使用 shift+or 优化?我怀着很高的希望阅读了这篇文章,因为我在我编写的演示 [1] 中遇到了像素操作性能问题,这意味着我不得不将画布大小从全屏缩减到 400×400。

          正如您所说,一个可能的解决方案是检查字节序,并对每个字节序使用不同的逻辑。我很好奇想知道,调用函数 A 或函数 B 来将数据分配给像素的性能损失是否高于分别管理每个通道。

          我实际上开始怀疑字节序是否应该暴露给 JS 开发人员。Intel 的一篇论文 [2] 表明 JVM 是大端的;也许 JS 引擎也应该呈现一个通用的字节序,而与底层硬件无关。这可行吗?

          [1] http://barryvan.github.com/trackPerformer/joy.html
          [2] http://www.intel.com/design/intarch/papers/endian.pdf

          2011 年 12 月 1 日 下午 5:29

          1. Boris

            您还可以使用类型化数组设置器,它们将执行显式字节序转换。当然,它们可能比将分配给本机字节序数组条目要慢。

            是的,将字节序暴露给 JS 显然不是一个好主意……。

            2011 年 12 月 2 日 上午 5:55

        3. Gordon

          我不太确定。许多解释型语言在其运算符中模拟小端位,以便左移和右移分别变为移位更大/更小。Javascript 也许也是这样。

          2011 年 12 月 1 日 下午 5:59

        4. Joe

          在 Google 搜索后,iPhone/iPad 似乎都像 x86 一样是小端的,许多 Android 设备也是如此。

          2011 年 12 月 2 日 下午 12:55

          1. Boris

            ARM 处理器可以是小端的,也可以是大端的;在许多情况下,您实际上可以在运行时(当然是在启动时)选择,并得到操作系统的支持。

            实际上,许多出厂的 ARM 设备只支持小端操作。

            2011 年 12 月 2 日 下午 12:57

  2. David Wilhelm

    Firefox 是唯一支持 Uint8ClampedArray 的浏览器吗?

    2011 年 12 月 1 日 下午 7:03

    1. Andrew J. Baker

      目前,我知道只有 Firefox 将 CanvasPixelArray 替换为 Uint8ClampedArray 作为 ImageData 对象的 data 属性的类型,以符合最新的 HTML5 规范。

      http://www.whatwg.org/specs/web-apps/current-work/#imagedata

      2011 年 12 月 2 日 下午 2:15

  3. Jon

    Firefox 支持,但 Chrome(以及 Android)不支持。

    2011 年 12 月 1 日 下午 7:08

  4. 4esn0k

    真是奇怪的测试 - http://jsperf.com/canvas-pixel-manipulation - 只有 Firefox,只有 TypedArrays

    2011 年 12 月 1 日 下午 8:17

  5. Joe

    这里有一个小变通方法:http://jsperf.com/canvas-pixel-manipulation/8

    我添加了第三个测试,它使用 Int32Array,并直接作用于画布图像数据使用的缓冲区。它不再需要手动生成“Uint8ClampedArray”,但仍然是 Firefox 专用的,因为画布上下文返回的图像数据仍然需要是其中之一才能使所有这些操作正常工作。

    我在发现某些情况下,Uint32Array 在设置值时比 Int32Array 显著慢之后构建了它。只有 Firefox 11 在该测试中显示出差异,但在我的代码中,我在 FF 8 中也发现了差异(但无法在 JSPerf 上复制它)。

    通过直接作用于图像数据缓冲区,您可以避免创建中间缓冲区,这在处理非常大的图像时很有用,这些图像的大小可能达到几兆字节。这也意味着不需要在最后进行“设置”(我认为这是一个缺点)。

    2011 年 12 月 4 日 上午 0:02

  6. dhaber

    我可能错了,但字节序测试不应该从 buf8 中读取字节,而不是从 buf 中读取?我在 chromium 上尝试了这个,无法从 buf 中读取字节,但 buf8 按预期工作。

    2012 年 4 月 16 日 上午 9:38

  7. Paul Neave

    Uint8ClampedArray 刚刚加入 WebKit,应该在 Google Chrome 20.0.1116(Canary)中可用:https://bugs.webkit.org/show_bug.cgi?id=73011

    2012 年 4 月 25 日 上午 2:05

    1. Andrew J. Baker

      太棒了!我也一直在跟踪这个。太棒了。我迫不及待地想知道性能是否会有显著提高,就像 Firefox 那样。

      2012 年 4 月 25 日 上午 8:39

    2. Andrew J. Baker

      开始看到 Chrome 的性能细节。

      http://jsperf.com/canvas-pixel-manipulation/6

      与 Firefox 相似的性能提升。太棒了!

      2012 年 5 月 9 日 上午 9:13

本文的评论已关闭。