编辑: 查看关于 字节序 的部分。
类型化数组 可以显著提升 HTML5 2D Canvas 网页应用的像素操作性能。对于那些想要使用 HTML5 开发基于浏览器的游戏的开发者来说,这一点尤其重要。
这篇文章是由 Andrew J. Baker 客座撰写的。Andrew 是一位专业的软件工程师,目前在 Ibuildings UK 工作,他的时间被平均分配到前端和后端企业 Web 开发。他是 Freenode 上基于浏览器的游戏频道 #bbg 的主要成员,在 2011 年 9 月的第一个 HTML5 游戏会议上发表了演讲,并且是 Mozilla 的 WebFWD 创新加速器项目的一位侦察员。
避免使用 Canvas 上绘制图像和原语的更高级方法,我们将直接操作像素,使用 ImageData。
传统 8 位像素操作
以下示例演示了使用图像数据进行像素操作,以在 Canvas 上生成灰度莫尔纹。
让我们逐步分析。
首先,我们从 DOM 获取到 Canvas 元素的引用,该元素的 id 属性为 canvas。
var canvas = document.getElementById('canvas');
接下来的两行可能看起来像是微优化,事实上它们确实是。但考虑到在主循环中访问 Canvas 宽度和高度的次数,将 canvas.width
和 canvas.height
的值分别复制到变量 canvasWidth
和 canvasHeight
中,可以对性能产生明显影响。
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);
注意,因为 data 是 imageData.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 属性的类型,则此示例将无法正常工作!
第一个与原始示例的偏差始于一个名为 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);
不要被“视图”这个词所误导。buf8 和 data 都可以 **读写**。有关 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 的处理器的 字节序。
但是,下面的代码通过测试目标处理器的字节序,然后根据处理器是大端还是小端执行不同的主循环版本,来纠正了这种疏忽。
针对此修改后的代码的 对应的 jsperf 测试 也是这样编写的,它显示出与原始 jsperf 测试几乎相同的结果。因此,我们的最终结论仍然相同。
非常感谢所有评论者和测试者。
关于 Paul Rouget
Paul 是一位 Firefox 开发者。
21 条评论