为什么要用 GPU
CPU 只有少数几个强大的核心,擅长复杂的分支和顺序逻辑;GPU 有成千上万个简单的核心,擅长把同一段程序同时跑在海量数据上。图像正好是这种数据:每个像素都按同一个公式计算,彼此独立。
左边:一个线程,逐个像素
JavaScript 版本是两层 for 循环,一个像素算完再算下一个。因为太慢,它只在降低后的分辨率(CPU resolution)下计算,再拉伸到左半屏。
右边:每个像素一个线程
片元着色器的 main() 只描述一个像素怎么算,GPU 会为屏幕上的每个像素各启动一次:
glsl
vec2 c = uCenter + px * uScale;
for (; i < uIter; i++) {
z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
if (dot(z, z) > 256.0) break;
}画一个盖满视口的大三角形,就等于“对每个像素运行一次这个函数”,这是 GPU 通用计算最古老的技巧。
SIMT 与分支发散
GPU 把线程按 32 或 64 个一组(warp / wavefront)调度,同组线程同时执行同一条指令。如果组内有的像素早早逃逸、有的要跑满迭代,先结束的线程只能空等,这叫分支发散。分形边界附近最慢,就是这个原因。
计时的陷阱
GPU 命令是异步提交的:drawArrays 返回时活可能还没干完。这里用 readPixels 读回一个像素,强迫 CPU 等 GPU 完成,测到的才是真实耗时。第一次绘制还包含着色器编译,所以先做一次预热。
在无头测试环境里,GPU 往往由 SwiftShader 这类软件渲染器模拟(其实还是在 CPU 上跑),测出的 GPU 时间可能比 JS 还慢。在真正的显卡上,右半边通常快几十到几百倍。
打开 auto zoom,CPU 每帧都要重算,你能直观感受到两者差距。
核心 API
canvas.getContext('webgl2')gl.drawArraysgl_FragCoordgl_VertexIDgl.readPixelsgl.texImage2DImageDataperformance.now动手试试
- 1把 iterations 调到 2000,对比左右两边的耗时怎么变化。
- 2把 CPU resolution 调到 1,看看 JS 在全分辨率下要花多久。
- 3删掉 drawGPU 后面的 readPixels,再看 GPU 计时变成了多少,想想为什么。
还没有输出。console.log 的内容会显示在这里。
运行中