3、WebGL基础与性能:WebGL上下文创建、状态机模型、绘制调用(Draw Call)开销分析

好,咱们进入第三章。这一章我打算聊聊WebGL最底层的几个概念。说实话,很多同学学了半年WebGL,画个三角形没问题,但一谈性能就懵了。为什么?因为没搞懂底层机制。今天咱们就把这三块硬骨头啃下来:上下文创建、状态机模型、还有Draw Call。

3.1 WebGL上下文创建——别小看这一步

创建上下文,听起来就是个初始化动作对吧?但我见过太多项目,死就死在这一步上。

核心要点:WebGL上下文不是随便创建的,它需要和GPU建立一条通信通道。这条通道的建立成本,比你想象的高得多。

先看标准写法:

const canvas = document.getElementById('myCanvas');
const gl = canvas.getContext('webgl');
// 或者
const gl2 = canvas.getContext('webgl2');

就这么两行?嗯,表面上看是的。但背后发生了什么?

  • 浏览器会检查GPU是否可用
  • 分配一块显存给这个上下文
  • 初始化WebGL状态机(后面会讲)
  • 建立JavaScript到GPU的指令通道

我个人习惯在创建上下文时加一些参数。比如:

const gl = canvas.getContext('webgl', {
  alpha: false,        // 不需要透明度通道
  antialias: false,    // 关闭抗锯齿
  premultipliedAlpha: false,
  preserveDrawingBuffer: false
});

为什么要关掉这些?说白了,每个选项背后都是GPU资源的消耗。我在项目中遇到过,一个简单的3D场景,开了抗锯齿后帧率直接掉了一半。你想想看,移动端GPU本来就不强,这些默认开启的特性就是性能杀手。

我的建议:除非你明确需要,否则把alpha和antialias都设为false。preserveDrawingBuffer也关掉,它会让GPU保留每一帧的渲染结果,内存开销很大。

3.2 状态机模型——WebGL的灵魂

WebGL本质上是一个状态机。这个概念我当年学的时候也觉得抽象,后来踩了坑才真正理解。

什么叫状态机?就是WebGL内部维护了一堆「开关」和「参数」。你调用一个API,其实就是在改变这些状态。然后下一次绘制,WebGL就按照当前所有状态去执行。

举个例子:

// 设置当前颜色为红色
gl.clearColor(1.0, 0.0, 0.0, 1.0);
// 清除颜色缓冲区
gl.clear(gl.COLOR_BUFFER_BIT);

这里clearColor就是在设置状态。它不会立即画任何东西,只是告诉WebGL:「下次清除的时候,用这个颜色。」

为什么会这样?因为GPU是流水线作业。它需要把所有状态都确定好了,才能高效地批量处理。你想想看,如果每画一个三角形都要重新配置一遍GPU,那效率得多低?

我曾经踩过的坑:有一次我在循环里反复调用gl.useProgram()gl.bindBuffer(),结果帧率惨不忍睹。后来才发现,这些API调用本身就有开销。状态切换越频繁,性能越差。

状态机模型带来的核心优化原则就一条:尽量减少状态切换。把所有相同状态的绘制操作放在一起,这就是所谓的「批处理」(Batching)。

3.3 绘制调用(Draw Call)开销分析

好,终于到重头戏了。Draw Call,说白了就是你告诉GPU「给我画这个」。每次调用gl.drawArrays()gl.drawElements(),就是一次Draw Call。

很多人以为Draw Call就是发一条指令,能有多慢?嗯,这里要注意:

  • 每次Draw Call,CPU要把数据打包成GPU能理解的格式
  • 通过总线传输到GPU
  • GPU解析指令、切换状态、开始渲染

这个过程虽然快,但架不住数量多。我做过一个测试:

Draw Call数量 帧率(60fps目标) CPU耗时
100 60fps 2ms
500 55fps 8ms
1000 30fps 16ms
2000 15fps 30ms+

看到了吗?当Draw Call超过500次,CPU就开始忙不过来了。而GPU其实还很闲,它在等CPU喂数据。

核心结论:WebGL的性能瓶颈往往不在GPU,而在CPU。Draw Call太多,CPU就成了瓶颈。

怎么优化?我常用的方法:

  1. 合并几何体:把多个小模型合并成一个大的,一次Draw Call画完
  2. 使用实例化绘制gl.drawArraysInstanced(),一次调用画多个相同物体
  3. 纹理图集:把多个小纹理合并成一张大图,减少纹理切换

举个例子,假设你要画1000个立方体:

// 糟糕的做法:每个立方体一次Draw Call
for (let i = 0; i < 1000; i++) {
  // 设置位置、旋转等
  gl.drawArrays(gl.TRIANGLES, 0, 36);
}
// 1000次Draw Call,性能完蛋

// 好的做法:合并所有顶点数据
// 一次性上传所有立方体的顶点
gl.drawArrays(gl.TRIANGLES, 0, 36000);
// 1次Draw Call,性能起飞

我个人习惯在项目初期就设定一个Draw Call预算。比如移动端控制在100以内,PC端控制在300以内。超过这个数,就要考虑优化了。

调试技巧:Chrome的Performance面板可以查看Draw Call数量。打开「WebGL」相关的跟踪选项,就能看到每次绘制的耗时。我每次做性能调优,第一件事就是看这个。

3.4 三者之间的关系

上下文、状态机、Draw Call,这三者是环环相扣的。

  • 上下文是基础,创建不好后面全白搭
  • 状态机是中间层,管理着所有绘制参数
  • Draw Call是最终的执行,受前两者影响

我见过一个项目,创建上下文时开了抗锯齿,然后状态切换频繁,Draw Call又高达2000次。结果就是:GPU在忙着做抗锯齿,CPU在忙着切换状态,两边都累,帧率只有个位数。

优化思路其实很简单:

  1. 创建上下文时,关掉不必要的特性
  2. 减少状态切换,把相同状态的绘制集中
  3. 合并Draw Call,能用一次绝不用两次

嗯,这一章的内容就到这里。下一章我们会聊纹理和缓冲区,这两个也是性能大户。到时候再细说。