1. 从"看不见的三角形"说起:透明渲染到底难在哪
很多人第一次在WebGL里尝试画透明物体时,都会遇到一个特别诡异的现象:明明代码里设置了透明度,三角形要么完全不透明,要么干脆消失不见,要么几个透明面叠在一起时颜色乱七八糟。我当初也踩过这个坑,盯着屏幕调了半天gl_FragColor的第四个分量,结果发现问题的根源根本不在着色器里,而在渲染状态的配置上。
这篇内容就是围绕"用α混合绘制透明三维物体"这个主题展开的。核心要解决的问题很具体:如何让WebGL正确地渲染出半透明的三角形,并且让多个透明面叠加时颜色符合视觉预期。关键词包括α混合、透明三角形、混合方程、深度测试、绘制顺序、预乘Alpha。适合已经能画出实心三角形、但对透明渲染一头雾水的WebGL初学者,也适合想回头把混合机制彻底搞清楚的进阶开发者。
先说一个反直觉的结论:透明渲染的难点从来不是"怎么让颜色变淡",而是"怎么让GPU知道该拿新颜色和旧颜色做什么运算"。你写vec4(1.0, 0.0, 0.0, 0.5)只是告诉片元着色器输出一个半透明红色,但GPU默认会把新颜色直接覆盖到帧缓冲上,那个0.5的alpha值根本没人理。要让alpha真正起作用,必须显式开启混合功能,并且告诉GPU用什么样的混合方程去计算最终像素。
打个生活化的比方:帧缓冲就像一块已经画了画的画布,你现在要往上再画一笔半透明的红色。默认行为是"用新颜料把旧地方完全盖住",而α混合的行为是"把新颜料和旧颜料按比例调匀再涂上去"。开启混合,本质上就是切换这两种作画方式。
理解了这一点,后面所有的配置——blendFunc、enable(BLEND)、绘制顺序——都是在回答同一个问题:新旧颜色按什么比例、什么公式混合。把这套逻辑理顺了,透明渲染就不再是玄学。
2. α混合的数学底子:源因子、目标因子与混合方程
2.1 混合方程到底在算什么
WebGL的混合发生在片元着色器输出之后、写入帧缓冲之前。GPU会拿两个颜色做运算:一个是源颜色(Source,简称Src),也就是当前片元着色器算出来的颜色;另一个是目标颜色(Destination,简称Dst),也就是帧缓冲里已经存在的颜色。混合方程的标准形式是这样的:
最终颜色 = 源颜色 × 源因子 + 目标颜色 × 目标因子这里的"源因子"和"目标因子"就是通过gl.blendFunc(srcFactor, dstFactor)来指定的。它们决定了源颜色和目标颜色各自占多大权重。注意,这个公式对RGB三个通道和Alpha通道是分别计算的,所以实际上有四个分量在同时做这套运算。
很多人卡住的地方在于:因子不是固定的数值,而是一个"模式"。比如gl.SRC_ALPHA表示"取源颜色的alpha值作为因子",gl.ONE_MINUS_SRC_ALPHA表示"取1减去源alpha作为因子"。GPU会在每个像素上动态计算这些因子,而不是用常量。
2.2 最常用的那组配置:SRC_ALPHA 与 ONE_MINUS_SRC_ALPHA
对于绝大多数"标准透明"场景,我们用的都是这一组:
gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);代入混合方程,假设源颜色是(1, 0, 0, 0.5),目标颜色是(0, 0, 1, 1)(一块不透明的蓝色背景):
源因子 = SRC_ALPHA = 0.5 目标因子 = ONE_MINUS_SRC_ALPHA = 1 - 0.5 = 0.5 最终RGB = (1,0,0) × 0.5 + (0,0,1) × 0.5 = (0.5, 0, 0.5)结果是一个紫色,正好是红色和蓝色各占一半。这就是我们直觉上期望的"半透明红色盖在蓝色上"的效果。alpha值越大,源颜色占比越高,物体看起来越不透明;alpha越小,背景透出来越多。
2.3 为什么默认不开启混合
这里有个设计上的考量值得说清楚。GPU默认关闭混合,是因为混合是有性能代价的。每开启一次混合,GPU就要多做一次读-改-写操作:先读出帧缓冲里的旧颜色,和源颜色做运算,再写回去。对于不透明的物体,直接覆盖写入就行,根本不需要读旧值。所以WebGL把混合做成"按需开启"的开关,让开发者自己决定什么时候付这个代价。
这也解释了一个常见困惑:为什么我设置了alpha但没效果?因为混合没开,alpha值被直接忽略了,GPU只用了RGB分量去覆盖。alpha值本身不会让颜色变淡,只有混合方程才会。
2.4 预乘Alpha:一个容易忽略的细节
还有一个进阶概念叫预乘Alpha(Premultiplied Alpha)。普通情况下,源颜色是(R, G, B, A),混合时用SRC_ALPHA作为因子,相当于把RGB乘以A。而预乘Alpha是指颜色在输出前就已经乘过A了,即(R×A, G×A, B×A, A)。这时候混合因子要改成gl.ONE:
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);两种方式在数学上等价,但预乘Alpha在某些场景下(比如纹理采样、多次混合)能避免颜色溢出和边缘发黑的问题。初学阶段用标准方式就够了,但知道这个概念,以后遇到透明边缘发暗的诡异现象时,就能想到可能是预乘与否的问题。
3. 深度测试与透明物体的"打架"问题
3.1 深度测试默认会"吃掉"后面的透明面
光开启混合还不够。WebGL默认开启了深度测试(gl.DEPTH_TEST),它的作用是:如果一个片元的深度值比帧缓冲里已有的深度值更远,就丢弃它。这对不透明物体是正确的——近的挡住远的。但对透明物体就出问题了。
想象你画一个半透明的立方体,六个面都是透明的。如果先画了前面的面,它的深度值被写入深度缓冲;再画后面的面时,因为深度更远,直接被深度测试丢弃了。结果就是:你只能看到最前面那一层,后面的透明面全没了,立方体看起来像个纸片。
3.2 关闭深度写入,但保留深度测试
正确的做法是:对透明物体关闭深度写入(depth write),但保留深度测试(depth test)。这样透明面之间不会互相遮挡,但透明物体仍然会被前面的不透明物体正确遮挡。
// 先画所有不透明物体,此时深度写入开启 gl.enable(gl.DEPTH_TEST); gl.depthMask(true); // 切换到透明物体 gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); gl.depthMask(false); // 关键:关闭深度写入 // 绘制透明物体... gl.depthMask(true); // 恢复gl.depthMask(false)的意思是:深度测试照常进行(远的透明面还是会被近的不透明物体挡住),但测试通过后不把深度值写进缓冲。这样后面的透明面就不会被前面的透明面"误伤"。
3.3 绘制顺序:从远到近
即使关闭了深度写入,透明物体的绘制顺序依然重要。因为混合是顺序相关的:先画A再画B,和先画B再画A,结果可能不同。正确的顺序是**从远到近(back-to-front)**绘制。
原因还是回到混合方程。假设两个半透明面,远的叫Far,近的叫Near。如果先画Near再画Far,Far会混合到已经包含Near的帧缓冲上,但Far其实在Near后面,物理上应该先被看到。结果就是颜色层次错乱。从远到近画,才能保证每一层都正确地"叠"在前一层之上。
对于简单的几个三角形,手动排序就行。对于复杂场景,需要按物体中心到相机的距离排序。这也是为什么透明渲染在游戏引擎里是个专门的话题——排序本身就有开销。
3.4 一个实用的渲染流程模板
把上面的要点串起来,一个稳妥的透明渲染流程是这样的:
- 清空颜色和深度缓冲
- 开启深度测试,开启深度写入
- 绘制所有不透明物体
- 开启混合,设置
blendFunc - 关闭深度写入(
depthMask(false)) - 按从远到近的顺序绘制所有透明物体
- 恢复深度写入
这个流程几乎适用于所有涉及透明物体的场景,记住它比记住零散的API调用有用得多。
4. 手把手:一个可运行的透明三角形Demo
4.1 顶点数据与着色器准备
我们画两个交叉的三角形,都带透明度,这样能直观看到混合和排序的效果。顶点数据里除了位置,再加一个alpha分量:
const vertices = new Float32Array([ // 三角形A:红色,alpha 0.6 -0.6, -0.5, 0.0, 1.0, 0.0, 0.0, 0.6, 0.6, -0.5, 0.0, 1.0, 0.0, 0.0, 0.6, 0.0, 0.6, 0.0, 1.0, 0.0, 0.0, 0.6, // 三角形B:蓝色,alpha 0.6 -0.6, 0.5, 0.0, 0.0, 0.0, 1.0, 0.6, 0.6, 0.5, 0.0, 0.0, 0.0, 1.0, 0.6, 0.0, -0.6, 0.0, 0.0, 0.0, 1.0, 0.6, ]);顶点着色器把位置和颜色都传给片元着色器:
// vertex shader attribute vec3 a_Position; attribute vec4 a_Color; varying vec4 v_Color; void main() { gl_Position = vec4(a_Position, 1.0); v_Color = a_Color; }片元着色器直接输出带alpha的颜色:
// fragment shader precision mediump float; varying vec4 v_Color; void main() { gl_FragColor = v_Color; }注意这里a_Color是vec4,第四个分量就是alpha。如果写成vec3,alpha默认是1.0,怎么调混合都不会透明。
4.2 关键配置代码
渲染循环里的配置是重点,我把它单独拎出来:
// 清屏 gl.clearColor(0.1, 0.1, 0.1, 1.0); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // 开启深度测试 gl.enable(gl.DEPTH_TEST); // 开启混合并设置方程 gl.enable(gl.BLEND); gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // 关闭深度写入,让透明面能互相叠加 gl.depthMask(false); // 绘制两个三角形 gl.drawArrays(gl.TRIANGLES, 0, 3); gl.drawArrays(gl.TRIANGLES, 3, 3); // 恢复深度写入 gl.depthMask(true);跑起来之后,你会看到红色和蓝色三角形重叠的区域呈现出紫色,而且两个三角形各自的半透明效果都很自然。如果去掉gl.enable(gl.BLEND),两个三角形就变成实心的,重叠处是后画的蓝色完全盖住红色。
4.3 验证混合是否真的生效
有个简单的自检方法:把blendFunc改成gl.blendFunc(gl.ONE, gl.ZERO),这等价于"源颜色×1 + 目标颜色×0",也就是完全覆盖,效果和不开混合一样。再改成gl.blendFunc(gl.ZERO, gl.ONE),就变成完全保留目标颜色,源颜色被丢弃,屏幕上什么都画不出来。通过切换这两组极端参数,能快速确认混合管线是否真的在工作。
4.4 常见报错与排查
如果画面全黑或者三角形不显示,按这个顺序排查:
- 检查
a_Color是不是vec4,alpha分量有没有正确传入 - 检查
gl.enable(gl.BLEND)有没有调用 - 检查
blendFunc的参数是不是SRC_ALPHA和ONE_MINUS_SRC_ALPHA - 检查
depthMask是不是设成了false - 检查清屏的alpha是不是1.0(如果清屏alpha是0,背景本身透明,可能和页面背景混在一起看不出来)
我遇到最多的情况是忘了开混合,其次是alpha分量在顶点数据里没对齐——比如位置是3个float,颜色写了3个float,结果步长算错,alpha读到了错误的位置。
5. 那些文档不会告诉你的实战经验
5.1 透明物体和不透明物体的绘制顺序不能反
这是新手最容易犯的错。如果先画透明物体再画不透明物体,不透明物体会因为深度测试把透明物体挡住,但透明物体其实应该在前面。正确顺序永远是先不透明,后透明。这个规则没有例外,因为不透明物体需要写深度来正确遮挡,而透明物体不写深度。
5.2 多个透明物体之间的排序开销
当透明物体数量多起来,按距离排序就成了性能瓶颈。一个常见的优化是按物体中心排序而不是按每个三角形排序,牺牲一点精度换性能。对于粒子系统这种大量小透明面,有时候干脆不排序,靠加法混合(blendFunc(gl.SRC_ALPHA, gl.ONE))来规避顺序问题——加法混合是可交换的,先画后画结果一样。代价是颜色会越叠越亮,适合火焰、光晕这类效果,不适合普通半透明。
5.3 alpha值不要设成0或1的边界
alpha=1就是完全不透明,alpha=0就是完全透明(等于没画)。这两个极端值在混合时虽然数学上没问题,但容易掩盖配置错误。调试阶段建议用0.5左右的中间值,这样混合效果肉眼可见,配置错了立刻能发现。
5.4 关于sRGB和颜色空间
如果项目里用了sRGB纹理,混合发生在哪个颜色空间会影响最终观感。线性空间下混合更符合物理,但sRGB空间下混合更符合传统美术直觉。这个问题在初学阶段可以先不管,但当你发现透明叠加的颜色"发灰"或者"过亮"时,可以往颜色空间的方向想一想。
5.5 一个我踩过的坑:depthMask恢复时机
有次我在一个复杂场景里,透明物体画完后忘了把depthMask恢复成true,结果下一帧的不透明物体全都不写深度了,整个场景的遮挡关系全乱。这种bug特别隐蔽,因为第一帧看起来正常,从第二帧开始才出问题。养成习惯:开启某个状态后,在合适的时机显式恢复,不要依赖默认值。
6. 从两个三角形到真实场景的扩展思路
掌握了两个透明三角形的绘制,其实已经覆盖了透明渲染90%的核心知识。往真实场景扩展时,主要面对的是规模和排序的问题。
如果是画一个半透明的玻璃球,可以把球面拆成很多三角形,全部关闭深度写入,然后按三角形中心到相机的距离排序。球面三角形数量不多时完全可行。如果是画一片半透明的树叶,可以用一张带alpha通道的纹理,在片元着色器里采样纹理的alpha,配合discard丢弃完全透明的片元,再对边缘做混合。这就是所谓的Alpha Test + Alpha Blend组合,是植被渲染的经典做法。
再进一步,如果要做半透明的烟雾或者水面,通常会用到加法混合或者自定义混合方程。加法混合让颜色叠加变亮,适合发光效果;自定义方程比如blendFuncSeparate可以分别设置RGB和Alpha的因子,实现更精细的控制。
我个人的建议是:先把这篇里的两个三角形彻底跑通、改参数观察变化,再去碰纹理和复杂场景。透明渲染的坑大多出在对基础机制理解不透,而不是API不会用。把blendFunc的每个参数组合都试一遍,把depthMask开关的效果对比一遍,比看十篇教程都管用。
最后分享一个调试小技巧:在片元着色器里临时把gl_FragColor设成vec4(v_Color.a, v_Color.a, v_Color.a, 1.0),把alpha值本身当灰度显示出来。这样你能一眼看出每个像素的alpha到底是多少,快速定位是alpha数据传错了,还是混合配置错了。这个技巧帮我省过无数次瞎猜的时间。