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一个容易忽略的细节还有一个进阶概念叫预乘AlphaPremultiplied Alpha。普通情况下源颜色是(R, G, B, A)混合时用SRC_ALPHA作为因子相当于把RGB乘以A。而预乘Alpha是指颜色在输出前就已经乘过A了即(R×A, G×A, B×A, A)。这时候混合因子要改成gl.ONEgl.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再画FarFar会混合到已经包含Near的帧缓冲上但Far其实在Near后面物理上应该先被看到。结果就是颜色层次错乱。从远到近画才能保证每一层都正确地叠在前一层之上。对于简单的几个三角形手动排序就行。对于复杂场景需要按物体中心到相机的距离排序。这也是为什么透明渲染在游戏引擎里是个专门的话题——排序本身就有开销。3.4 一个实用的渲染流程模板把上面的要点串起来一个稳妥的透明渲染流程是这样的清空颜色和深度缓冲开启深度测试开启深度写入绘制所有不透明物体开启混合设置blendFunc关闭深度写入depthMask(false)按从远到近的顺序绘制所有透明物体恢复深度写入这个流程几乎适用于所有涉及透明物体的场景记住它比记住零散的API调用有用得多。4. 手把手一个可运行的透明三角形Demo4.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。如果写成vec3alpha默认是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是不是vec4alpha分量有没有正确传入检查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的边界alpha1就是完全不透明alpha0就是完全透明等于没画。这两个极端值在混合时虽然数学上没问题但容易掩盖配置错误。调试阶段建议用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数据传错了还是混合配置错了。这个技巧帮我省过无数次瞎猜的时间。