4、颜色与缓冲区:顶点颜色与插值、使用缓冲区传递数据、多属性顶点数据、颜色混合与透明度

好,咱们继续往下走。上一章我们把三角形画出来了,但那个颜色是写死在着色器里的,说白了就是「死颜色」。实际项目中,你不可能每个模型都去改着色器代码吧?那太傻了。这一章,我们就来聊聊怎么让颜色「活」起来——通过顶点颜色、缓冲区传数据,还有颜色混合这些玩意儿。

4.1 顶点颜色与插值:从硬编码到渐变

先想一个问题:一个三角形有三个顶点,我给每个顶点一个不同的颜色,那三角形中间的颜色应该是什么?

答案是:插值

WebGL 的渲染管线里,顶点着色器输出颜色后,片元着色器拿到的颜色是经过插值的。也就是说,三个顶点分别是红、绿、蓝,那三角形中间就会是一个平滑的渐变。这个机制非常有用,你想想看,做光照效果、做地形高度图,本质上都是靠插值。

我个人习惯把这种技术叫做「穷人的渐变」——不需要纹理,几个顶点就能做出很丰富的色彩过渡。

核心概念: 顶点颜色 → 顶点着色器输出 → 光栅化插值 → 片元着色器接收 → 每个像素颜色不同

来看一个最简单的例子。我们给三个顶点分别赋值红、绿、蓝:

// 顶点数据:位置 (x, y) 和颜色 (r, g, b, a)
const vertices = new Float32Array([
  // 位置        // 颜色
   0.0,  0.5,   1.0, 0.0, 0.0, 1.0,  // 顶点0:红色
  -0.5, -0.5,   0.0, 1.0, 0.0, 1.0,  // 顶点1:绿色
   0.5, -0.5,   0.0, 0.0, 1.0, 1.0   // 顶点2:蓝色
]);

注意看,每个顶点现在有 6 个数据:前两个是位置,后四个是颜色。这就引出了下一个问题——怎么告诉 WebGL 这些数据是「混在一起」的?

4.2 使用缓冲区传递数据:从 CPU 到 GPU 的快递

缓冲区(Buffer)是 WebGL 里最基础的数据传递方式。说白了,就是你把数据打包好,塞进显存里,然后告诉 GPU:「嘿,数据在哪儿,怎么读。」

我记得刚开始学的时候,总搞不清 bufferDatavertexAttribPointer 的关系。后来想通了:bufferData 是发货,vertexAttribPointer 是告诉快递员怎么拆包裹。

步骤其实就三步:

  1. 创建缓冲区gl.createBuffer()
  2. 绑定并填充数据gl.bindBuffer() + gl.bufferData()
  3. 告诉 GPU 怎么解析gl.vertexAttribPointer() + gl.enableVertexAttribArray()

这里有个坑,我曾经踩过好几次——步长(stride)和偏移量(offset)算错。你看上面的数据,每个顶点 6 个 float,每个 float 4 字节,所以步长是 6 * 4 = 24 字节。位置从第 0 字节开始,颜色从第 8 字节开始(因为前两个 float 占了 8 字节)。

// 位置属性:从第0字节开始,每24字节取2个float
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 24, 0);
// 颜色属性:从第8字节开始,每24字节取4个float
gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, 24, 8);
避坑指南: 我曾经因为 stride 写成了 0(表示紧密排列),结果颜色数据全读歪了,画出来的三角形花得跟调色盘打翻了一样。记住:交错数据一定要算对步长和偏移量,单位是字节,不是元素个数。

4.3 多属性顶点数据:位置、颜色、法线一起上

实际项目中,一个顶点往往不止位置和颜色。还有法线、纹理坐标、切线、骨骼权重……你想想看,一个复杂的 3D 模型,每个顶点可能有十几二十个属性。

处理多属性的核心思路就一个:每个属性单独一个缓冲区,或者交错在一个缓冲区里。两种方式各有优劣:

方式 优点 缺点
单独缓冲区 逻辑清晰,更新某个属性不影响其他 绑定次数多,draw call 开销略大
交错缓冲区 一次绑定,数据局部性好,性能更优 更新麻烦,步长计算容易出错

我个人习惯在项目初期用单独缓冲区,调试方便。等到性能优化阶段,再合并成交错布局。嗯,这里要注意:不要过早优化,先把功能跑通再说。

来看一个带位置、颜色、纹理坐标的例子:

// 每个顶点 8 个 float:位置2 + 颜色4 + 纹理坐标2
const vertices = new Float32Array([
  // 位置    // 颜色          // 纹理
   0.0, 0.5, 1.0, 0.0, 0.0, 1.0, 0.5, 1.0,
  -0.5,-0.5, 0.0, 1.0, 0.0, 1.0, 0.0, 0.0,
   0.5,-0.5, 0.0, 0.0, 1.0, 1.0, 1.0, 0.0
]);

// 步长 = 8 * 4 = 32 字节
const stride = 32;
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0);
gl.vertexAttribPointer(colorLoc, 4, gl.FLOAT, false, stride, 8);
gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, stride, 24);

你看,只要把偏移量算对,加多少个属性都行。GPU 那边只管按规则读数据,它才不管你是颜色还是法线。

4.4 颜色混合与透明度:让物体「透」起来

终于到了透明度。说实话,透明度是 WebGL 里最容易出 bug 的地方之一。我见过太多人一上来就开混合,结果渲染顺序不对,画面乱七八糟。

先说说混合的原理。WebGL 默认是覆盖模式——后面的像素直接盖住前面的。开启混合后,它会根据公式把新像素和已有像素「混」在一起:

gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

这个公式的意思是:最终颜色 = 源颜色 × 源透明度 + 目标颜色 × (1 - 源透明度)。说白了就是「越透明的物体,越能看到后面的东西」。

小技巧: 如果你想让半透明物体看起来更「亮」一点,可以试试 gl.blendFunc(gl.SRC_ALPHA, gl.ONE),这是加法混合,适合做发光、火焰效果。我在做粒子系统时经常用这个。

但是!有个大坑——深度测试和透明度的冲突。WebGL 默认开启深度测试,先画的物体被挡住的部分就不会再画了。但半透明物体需要看到后面的东西,所以深度测试反而成了障碍。

我曾经在一个项目里调试透明水面的效果,折腾了两天才发现是渲染顺序的问题。解决方案其实不复杂:

  1. 先画所有不透明物体(深度测试正常开)
  2. 再画半透明物体(从远到近排序,关闭深度写入)

嗯,这里要注意:关闭深度写入gl.depthMask(false),不是关闭深度测试。深度测试还是要开的,否则半透明物体之间的遮挡关系就乱了。

// 渲染半透明物体的标准流程
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.depthMask(false);  // 关闭深度写入

// 按从远到近的顺序绘制半透明物体
drawTransparentObjectsSortedByDistance();

gl.depthMask(true);   // 恢复深度写入
gl.disable(gl.BLEND);
总结一下这一章的核心:
  • 顶点颜色通过插值实现渐变,这是 GPU 的天然能力
  • 缓冲区是 CPU 到 GPU 的数据通道,步长和偏移量是命门
  • 多属性数据可以交错或分离,根据项目阶段选择
  • 透明度混合要配合深度测试,注意渲染顺序

下一章我们会聊聊纹理——这才是真正让画面丰富起来的东西。到时候你会发现,颜色插值和缓冲区操作都是纹理的基础。先把今天的内容消化掉,动手写几个例子,比光看强一百倍。