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:「嘿,数据在哪儿,怎么读。」
我记得刚开始学的时候,总搞不清 bufferData 和 vertexAttribPointer 的关系。后来想通了:bufferData 是发货,vertexAttribPointer 是告诉快递员怎么拆包裹。
步骤其实就三步:
- 创建缓冲区:
gl.createBuffer() - 绑定并填充数据:
gl.bindBuffer()+gl.bufferData() - 告诉 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);
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 默认开启深度测试,先画的物体被挡住的部分就不会再画了。但半透明物体需要看到后面的东西,所以深度测试反而成了障碍。
我曾经在一个项目里调试透明水面的效果,折腾了两天才发现是渲染顺序的问题。解决方案其实不复杂:
- 先画所有不透明物体(深度测试正常开)
- 再画半透明物体(从远到近排序,关闭深度写入)
嗯,这里要注意:关闭深度写入 是 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 的数据通道,步长和偏移量是命门
- 多属性数据可以交错或分离,根据项目阶段选择
- 透明度混合要配合深度测试,注意渲染顺序
下一章我们会聊聊纹理——这才是真正让画面丰富起来的东西。到时候你会发现,颜色插值和缓冲区操作都是纹理的基础。先把今天的内容消化掉,动手写几个例子,比光看强一百倍。