先测量,再优化
GPU 是流水线:顶点处理、光栅化、片元着色、写入帧缓冲。整帧的速度由最慢的那一段决定。所以优化前要先找出瓶颈:动一个旋钮,看时间变不变。
读出 GPU 时间
JS 里的 performance.now() 只能量到提交命令的时间。EXT_disjoint_timer_query_webgl2 让 GPU 自己计时:
js
gl.beginQuery(ext.TIME_ELAPSED_EXT, q);
// ... 所有绘制 ...
gl.endQuery(ext.TIME_ELAPSED_EXT);
// 几帧之后:
if (gl.getQueryParameter(q, gl.QUERY_RESULT_AVAILABLE)) ns = gl.getQueryParameter(q, gl.QUERY_RESULT);结果是异步的,要隔几帧再取;如果 GPU_DISJOINT_EXT 为真(比如显卡切换了频率),这次结果作废。扩展不可用时退回 rAF 帧间隔,但它被垂直同步卡在 16.7 ms,只有掉帧时才看得出差别。
四类开销
- 填充率与重绘:每多一层全屏混合,每个像素就多着色一次、多读写一次帧缓冲。粒子、半透明 UI、体积雾都是重绘大户。
- 片元运算:
ALU iterations控制每个像素的循环次数。像素数乘以每像素运算量,增长非常快。 - 纹理带宽:
texture taps是每像素采样次数,texture spread把采样点打散。相邻像素读相邻纹素时缓存命中率高;打散之后几乎每次都要去显存取。这张噪声纹理故意没有 mipmap,mipmap 本身就是改善缓存的手段。 - 顶点数:环面结的顶点完全由
gl_VertexID生成。顶点过多、三角形比像素还小时,光栅化效率也会下降。
glsl
for (int i = 0; i < uIter; i++) {
q += 0.22 / (1.0 + fi * 0.35) * sin(q.yx * (1.6 + fi * 0.21) + ...);
}分辨率:最有效的旋钮
resolution scale 先渲染到较小的离屏缓冲,再用 blitFramebuffer 放大。缩放 0.5 意味着像素只有四分之一,所有片元开销同时降到四分之一。高分屏上的游戏几乎都这么做。
专业工具
- Chrome DevTools → Performance:看 CPU 主线程和 GPU 进程的时间线,找出卡顿帧。
- Spector.js:抓取一帧 WebGL 的全部调用、状态和中间纹理。
- WebGPU
timestamp-query:在计算或渲染 pass 的开始和结束写时间戳,精确到每个 pass。
在软件 GPU(如 SwiftShader)上,数字会比真实显卡大几十倍,但相对变化依然有意义:哪个旋钮让时间翻倍,哪个就是瓶颈。
核心 API
EXT_disjoint_timer_query_webgl2gl.beginQuerygl.getQueryParametergl.blitFramebuffergl.renderbufferStoragegl.blendFuncgl_VertexIDtimestamp-query动手试试
- 1把 overdraw layers 从 6 调到 24,再把 resolution scale 调到 0.5,比较两次 GPU 时间的变化。
- 2关掉 draw layers,只把 vertices 调到 3.1M,看顶点开销在总时间里占多少。
- 3把 texture taps 调到 16,然后把 spread 从 1 拉到 64:采样次数没变,时间为什么变了?
还没有输出。console.log 的内容会显示在这里。
运行中