4、WebGL基础与优化:理解WebGL渲染管线,使用顶点缓冲对象(VBO)和索引缓冲对象(EBO),减少draw call次数。

好,咱们进入第四章。说实话,WebGL 这块内容,很多做仪表盘的同学容易忽略。大家总觉得「能画出来就行」,结果一上复杂场景,帧率直接崩到个位数。我当年第一次做虚拟座舱时,就吃过这个亏。

WebGL 说白了,就是浏览器里的 OpenGL ES 2.0。它没有那些花里胡哨的高级 API,一切都要你自己动手。但正因如此,你才能精确控制每一帧的绘制成本。

4.1 理解WebGL渲染管线

先聊聊渲染管线。很多人觉得这东西太理论,其实不然。你想想看,你写的每一行着色器代码,最终都要经过这条流水线。不理解它,你连性能瓶颈在哪都找不到。

WebGL 的渲染管线大致分这么几步:

  1. 顶点数据 —— 你传给 GPU 的那些坐标、颜色、纹理坐标
  2. 顶点着色器 —— 处理每个顶点的位置变换
  3. 图元装配 —— 把顶点连成三角形
  4. 光栅化 —— 把三角形变成屏幕上的像素点
  5. 片段着色器 —— 计算每个像素的最终颜色
  6. 测试与混合 —— 深度测试、透明度混合等

这里有个关键点:顶点着色器片段着色器是你唯一能控制的阶段。其他阶段,WebGL 内部帮你搞定了。

性能核心原则:尽量减少顶点着色器的计算量,尽量减少片段着色器的执行次数。前者靠减少顶点数,后者靠减少像素覆盖。

我在项目中遇到过一个问题:一个仪表盘上有 200 多个指针,每个指针用了 36 个顶点。结果顶点着色器里还做了矩阵求逆运算。帧率直接掉到 15fps。后来我把矩阵运算挪到 CPU 端预计算,顶点着色器只做乘法,帧率瞬间回到 60fps。

4.2 顶点缓冲对象(VBO) —— 把数据交给GPU

好,现在说说 VBO。VBO 是什么?就是你把顶点数据(位置、颜色、法线等)提前上传到 GPU 显存里。这样每次绘制时,GPU 直接从显存读数据,不用再从 CPU 内存拷贝。

没有 VBO 的时代,大家用 glBegin()glEnd() 逐顶点提交数据。那效率,惨不忍睹。每次绘制都要走 PCIe 总线传数据,带宽全浪费在传输上了。

用 VBO 的流程其实很简单:

// 1. 创建 VBO
const vbo = gl.createBuffer();

// 2. 绑定到目标
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);

// 3. 上传数据
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);

// 4. 告诉 WebGL 如何解析数据
gl.vertexAttribPointer(location, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(location);

// 5. 绘制
gl.drawArrays(gl.TRIANGLES, 0, vertexCount);

嗯,这里要注意 gl.STATIC_DRAW 这个参数。它告诉 GPU:「这些数据不怎么变」。如果你要频繁更新数据(比如每帧更新顶点位置),应该用 gl.DYNAMIC_DRAWgl.STREAM_DRAW。选错了,性能会受影响。

个人经验:对于仪表盘这种场景,大部分几何体是静态的(表盘刻度、指针形状)。我习惯把静态数据用 STATIC_DRAW 上传,只有指针角度这种动态数据才用 DYNAMIC_DRAW。分开管理,清晰又高效。

4.3 索引缓冲对象(EBO) —— 省掉重复顶点

接下来是 EBO。这东西和 VBO 是黄金搭档。

你想想看,一个矩形需要两个三角形,也就是 6 个顶点。但矩形只有 4 个角,有 2 个顶点是重复的。如果场景里有 1000 个矩形,那就多出了 2000 个重复顶点。

EBO 就是来解决这个问题的。它存储的是顶点的索引,而不是顶点本身。GPU 根据索引去 VBO 里取顶点数据。

// 顶点数据(只有4个不重复的顶点)
const vertices = [
  -0.5, -0.5, 0.0,  // 左下
   0.5, -0.5, 0.0,  // 右下
   0.5,  0.5, 0.0,  // 右上
  -0.5,  0.5, 0.0   // 左上
];

// 索引数据(6个索引,组成2个三角形)
const indices = [
  0, 1, 2,  // 第一个三角形
  0, 2, 3   // 第二个三角形
];

// 创建 EBO
const ebo = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ebo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW);

// 绘制时用 drawElements 代替 drawArrays
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);

你看,顶点从 6 个减到了 4 个。对于复杂模型,节省的顶点数非常可观。我做过一个仪表盘,表盘上有 360 个刻度线。每个刻度线是一个矩形,用 EBO 后顶点数从 2160 降到了 1440,节省了三分之一。

注意:索引类型用 UNSIGNED_SHORT 还是 UNSIGNED_INT?如果顶点数不超过 65535,用 UNSIGNED_SHORT(2字节)。超过的话才用 UNSIGNED_INT(4字节)。别一上来就用 INT,浪费显存带宽。

4.4 减少draw call次数 —— 性能的关键

好,到了最核心的部分。什么是 draw call?就是你调用 gl.drawArrays()gl.drawElements() 的次数。

每次 draw call,CPU 都要做一堆准备工作:验证状态、更新上下文、提交命令到 GPU 命令缓冲区。这个过程是有开销的。一次两次无所谓,但如果你有上千次 draw call,CPU 就忙不过来了。

我见过一个极端案例:有人画 1000 个独立的点,每个点调用一次 gl.drawArrays()。结果 CPU 占用率 100%,GPU 闲得发慌。这就是典型的「CPU 瓶颈」。

怎么减少 draw call?几个实用技巧:

  • 合并几何体:把多个小物体合并成一个大的 VBO。比如 100 个指针,如果它们共享相同的顶点结构,可以合并成一个 draw call。
  • 使用实例化绘制gl.drawArraysInstanced() 可以一次绘制多个相同的物体,每个物体有不同的变换矩阵。
  • 纹理图集:把多个小纹理合并成一张大纹理,减少纹理切换次数。

我个人的习惯是:对于静态仪表盘元素,尽量合并成一个 draw call。比如表盘背景、刻度线、数字标签,这些都不怎么动,完全可以放在一个 VBO 里。动态元素(指针、数值变化)单独处理。

场景 优化前 draw call 数 优化后 draw call 数 帧率提升
简单仪表盘(50个元素) 50 3~5 2~3倍
复杂仪表盘(200个元素) 200 8~12 5~8倍
虚拟座舱(500+元素) 500+ 15~20 10倍以上

你看这个表格,优化效果非常明显。我曾经接手过一个虚拟座舱项目,原始代码有 600 多次 draw call,帧率只有 12fps。我花了两天时间重构,把所有静态元素合并到 5 个 VBO 里,draw call 降到 18 次,帧率直接飙到 60fps。

避坑指南:我曾经犯过一个错误——为了减少 draw call,把所有东西都塞进一个 VBO。结果因为数据量太大,每次更新一小部分都要重新上传整个缓冲区。后来我学乖了:静态数据和动态数据分开存放。静态数据用 STATIC_DRAW,动态数据用 DYNAMIC_DRAW,互不干扰。

4.5 实战建议

最后,给几个实战建议:

  1. 先用 Chrome DevTools 的 Performance 面板 看看 draw call 数量。如果超过 100,就该优化了。
  2. 能用 EBO 就用 EBO。对于仪表盘这种矩形居多的场景,EBO 能省 30%~50% 的顶点数据。
  3. 别在着色器里做复杂计算。能预计算的,在 CPU 端算好再传进去。
  4. 注意显存占用。VBO 和 EBO 的数据是存在显存里的,别一股脑全塞进去。用 gl.deleteBuffer() 及时清理不再用的缓冲区。

嗯,WebGL 基础就聊到这儿。下一章咱们会深入着色器编程,讲讲怎么用 GLSL 写出高效的仪表盘渲染效果。到时候再聊。