透明渲染这毛病,我最早是在做某跨平台系统的三维场景时碰上的:一个半透明玻璃球,里面还套着一根同样半透明的管道,转着转着,管道就穿到玻璃外面来了;再转两下,玻璃内壁自己把自己糊成一片。后来做大屏可视化,树叶、烟尘、水面,一个比一个能出幺蛾子。这些问题有个共同名字——“自身错乱”,本质就是半透明像素在混合时排序出了岔子。这篇文章专门聊 Babylon.js 环境下的两个核心解法:深度预渲染(Depth PrePass)和顺序无关透明(OIT)。前者能让半透明物体和不透明场景正确咬合,后者能从原理上绕开排序问题,适合处理多层透明互相嵌套的场景。无论是前端可视化、WebGL 项目,还是刚接触 Babylon.js 的图形学爱好者,读完都应该能定位并修复大部分透明渲染异常。
1. 透明渲染“自身错乱”到底是怎么来的
1.1 半透明排序是绕不开的“画家算法”
要理解自身错乱,先得理解浏览器里绝大多数实时渲染是怎么处理透明物体的。默认情况下,不透明物体渲染时开启深度测试和深度写入,谁在前面谁挡住后面,干干净净。半透明物体不行,因为颜色要和后面的像素混合,如果直接写入深度,那背后的东西就全被挡掉,玻璃就成实心的了。所以引擎把半透明物体按离相机从远到近排序,先画远的,再画近的,一层层叠加,这就是经典的画家算法。
Babylon.js 内部会给每个半透明 mesh 计算一个排序点,多数时候用物体中心点或者包围球中心,然后按这个距离排序。单看一个玻璃球没问题,球内球外分得清;一旦出现两个互相穿插的半透明物体,或者一个多面体自身有多个半透明面片互相折叠,排序点只能代表“大概位置”,代表不了每个三角形片段的真实先后,于是该在后面的片段先画了,该在前面遮挡的反而后画,画面就乱套。
我踩过最典型的一个坑是:一块半透明水面上漂浮着半透明气泡,水面 mesh 的中心点离相机远,气泡中心点离相机近,引擎把水面排后面、气泡排前面,看起来正常。可相机一压低,水面中心点的距离突然变得比气泡更近,排序反过来,气泡就被水面整个盖住了,视觉上直接从水面上消失。这就是“自身错乱”最直观的表现——同一组模型,换个角度就翻脸。
1.2 深度测试和混合是两套逻辑
不少人以为把深度测试打开就能解决半透明排序,其实半透明渲染的难点恰恰在于深度和混合是两套独立逻辑。深度测试管的是“像素能不能被画上去”,混合管的是“画上去之后怎么和背景色融合”。对于不透明物体,深度测试就能搞定一切;对于半透明物体,深度测试只能决定这个像素是否被遮挡,可它在混合时到底应该和谁融合,还是得靠绘制顺序。
举个例子:你从正面看一块双层玻璃,后面一层先画,颜色先写进颜色缓冲,前面一层后画,把颜色混合上去,视觉上透光、反射都对。但如果你把相机转到背面,引擎重新排序后,原本前面那层现在在几何上靠后了,它变成了先画的一层,于是颜色混合顺序完全反过来,玻璃要么变暗,要么边缘出现奇怪的色偏。
这还不是最麻烦的,最麻烦的是透明物体内部面片自身的顺序。一个模型由几百个三角形拼成,引擎按 mesh 排序,不按三角形排序,内部三角形之间谁的像素先混合完全不可控。这就是为什么很多半透明模型一旋转,内部结构就像粥一样糊在一起。想从根本上稳住画面,就不能只靠排序,要用深度预渲染把不透明的“底”打扎实,再用 OIT 去处理透明层与透明层之间的纠缠。
2. 深度预渲染:先把不透明的“底”画清楚
2.1 深度预渲染解决的是“透明和不透明”的错乱
深度预渲染的思路非常直接:在正式渲染一帧之前,先把场景里不透明的物体单独画一遍,只写深度,不做颜色输出。这一遍生成的就是一张完整的深度图。等真正渲染透明物体时,GPU 拿着这张深度图做深度测试,透明像素若在不透明表面后面,就直接被挡掉;若在前面,就正常通过并且混合。这样,透明物体和不透明物体之间的遮挡关系就从“靠排序碰运气”变成了“靠深度缓冲定生死”。
Babylon.js 里要启用这套机制,核心有两条路。一条是启用 PrePassRenderer,然后让对应材质参与深度预通道:
// 开启场景预通道渲染器 scene.prePassRenderer = new BABYLON.PrePassRenderer(scene); // 需要参与深度预渲染的材质,逐一打开 glassMaterial.enableDepthPrePass = true; leafMaterial.enableDepthPrePass = true;另一条是单独给相机创建深度渲染器,拿到的深度纹理可以喂给后处理或自定义 shader:
const depthRenderer = scene.enableDepthRenderer(camera, true); // 之后可以取 depthRenderer.getDepthMap() 做深度纹理采样就我实际项目里的感受,绝大多数“半透明物体穿到墙里”“半透明物体把后面的不透明柱子挡没了”这类问题,都是深度预渲染可以稳定解决的。它不改变透明物体之间的排序逻辑,但把透明层和不透明层的边界彻底立住了。
2.2 多个深度通道会不会把性能拖垮
添加预通道,意味着每帧要多渲染一遍不透明物体的顶点和深度,GPU 的 draw call 数量会上升,顶点处理工作量也会增加。对静态场景,这个开关带来的开销并不大,因为深度预通道不需要跑片段着色器的复杂光照,很多材质只用顶点位置和一个极简的片段输出。但动态场景、大量蒙皮骨骼动画、植被摇摆这类高顶点数模型,开启后帧率下降会非常明显。
我做过一次实测:一个中等规模的场景,不透明模型面数约 1200 万,开启 PrePassRenderer 后帧耗时从约 6ms 涨到 8ms。虽然体感不明显,但同一个场景如果跑在低端移动设备上,这个增量可能直接从 60fps 掉到 45fps 以下。
所以深度预通道不建议无脑全开,最好只给那些真正和半透明物体相交、且会产生明显视觉错误的不透明材质打开。比如一面会被玻璃穿透的墙壁,开启预通道收益极大;而远处一座和半透明区域毫无关系的山头,开了纯属浪费。Babylon.js 的 PrePassRenderer 本身也支持按材质控制,逐材质打开,而不是场景级一刀切。
2.3 alphaTest 材质同样受益
深度预渲染对 alphaTest 材质特别有价值。树叶、栅栏、铁丝网这类用 alphaTest 裁掉像素的物体,裁剪后边缘像素是非黑即白的,不做混合,因此可以正常写入深度。把树叶的材质设置成参与深度预通道后,树叶后面再放一面半透明玻璃,玻璃会被树叶精确遮挡,而不是整片玻璃直接盖到树叶上方。
这里有个容易被忽略的细节:alphaTest 裁出来的边缘往往有锯齿,如果你开着抗锯齿,深度预通道用的深度纹理本身和最终主渲染的几何边缘可能有一两个像素的偏差,结果就是玻璃和树叶交界处偶尔出现闪烁细线。遇到这种情况,我的习惯是把 alphaTest 材质的边缘加一圈淡出过渡,让裁剪边缘不是一刀切,既缓和锯齿,也减少深度偏差带来的闪烁。
3. Babylon.js 实操:透明材质配置与排序优化
3.1 四种透明模式先分清楚
Babylon.js 材质里有几个经典透明度模式,搞错的话后面怎么调都白搭:
- 默认 OPAQUE:完全不透明,适合绝大多数实体。
- ALPHATEST:像素 alpha 低于阈值直接丢弃,适合树叶、栏杆,边缘锐利,性能好。
- ALPHABLEND:真正做 alpha 混合,适合玻璃、水面、烟雾,边缘柔和但排序敏感。
- ALPHACOMBINE:以 diffuse 纹理 alpha 为准,做了纹理透视后整体透出,适合 UI 和贴图型半透明物体。
遇到“自身错乱”,第一步先确认材质属于哪一类。很多初学者把树叶材质设成 ALPHABLEND,结果树叶和树叶之间疯狂穿插,其实树叶用 ALPHATEST 就能解决绝大部分问题。ALPHABLEND 的材质数量越少,排序错乱的概率越低。
配置半透明材质时,我一般这么写:
const mat = new BABYLON.PBRMaterial("glass", scene); mat.transparencyMode = BABYLON.Constants.MATERIAL_ALPHABLEND; mat.alpha = 0.6; mat.disableDepthWrite = true; // 半透明混合通常不需要写深度 mat.backFaceCulling = false; // 双面渲染,避免薄片背面消失3.2 排序不是越细越好
Babylon.js 里可以调整 mesh 的排序策略,让引擎不只看中心点,也参考包围球半径等参数。但这些策略只是缓解,不能根治。排序策略调得越细,每帧排序计算量越大,而且细粒度排序在大场景里很容易导致画面不稳定——相机稍微一动,排序点就变,透明层顺序瞬间翻转。
我和 A 同学一起排查过一个项目:他把一张复杂的半透明建筑围挡拆成了几十个小组件,结果相机旋转时围挡上的文字一会儿浮在玻璃前,一会儿被玻璃吞掉。调了一圈排序策略,最后解决问题的反而是把围挡合并成一个整体 mesh,再配合深度预渲染。网格越碎,排序点越多,错乱概率越大;能合并的半透明网格尽量合并,这是最便宜的优化。
3.3 深度预渲染需要配合透明模式
开启深度预通道之后,不是一切就万事大吉了,它和 ALPHABLEND 的关系必须理清。深度预通道写入的是几何深度,而 ALPHABLEND 材质在正式渲染时往往关闭深度写入。如果材质本身既是半透明又参与预通道,那么预通道写入的深度就来自它自己的几何体,主渲染再做深度测试时,这个几何体会被自己预渲染的深度挡掉一部分。
这句话比较绕,用实际现象说就是:某些透明材质开启 enableDepthPrePass 后,反而出现“自己遮挡自己”的洞。解决方案很简单,让半透明材质不要写深度,也不要开预通道;只让场景中不透明的墙、地面、柱子参与深度预渲染。透明与透明的关系交给 OIT 或调整透明度模式去解决。除非你明确知道一个半透明物体既要被深度图记录,又不会被自身深度干扰,否则别轻易给 ALPHABLEND 材质开预通道。
4. OIT 顺序无关透明:排序失效时的最终解法
4.1 什么是顺序无关
如果场景里的半透明物体数量不多,排序问题忍一忍也就过去了。但当你有一堆烟雾、火焰、玻璃碎片互相交叠时,排序再怎么调都是按下葫芦浮起瓢。这时候就该上 OIT(Order Independent Transparency,顺序无关透明)。
名字就叫“与顺序无关”,意思是渲染结果不依赖物体绘制先后,每个半透明像素都按自己的深度权重参与最终混合,不需要引擎给它们排出一个全局先后顺序。OIT 最常见的实现之一,是加权混合 OIT(Weighted Blended OIT),它把每个半透明片段的颜色和深度转成一个权重,累加进一个颜色累积缓冲和一个权重累积缓冲,最后再做一个全屏合成。整个过程只用一个累积通道加一个合成通道,性能不错,画面质量也能满足大多数可视化场景需求。
4.2 加权混合 OIT 的伪代码
加权混合 OIT 的基本流程可以拆成两步:第一步把所有半透明物体的片段颜色和权重写进累积目标;第二步把累积目标和场景背景合成。
累积通道的片段着色器伪代码:
// 半透明片段累积:颜色暂存在浮点纹理里 uniform vec4 color; // 当前片段的半透明颜色 uniform float viewDepth; // 规范化后的深度值 [0,1] void main() { // 权重函数:越近权重越大,越远权重越小 float weight = clamp(pow(1.0 - viewDepth, 3.0), 0.01, 4.0); accumColor.rgb += color.rgb * color.a * weight; accumColor.a += color.a * weight; }合成通道的着色器伪代码:
uniform sampler2D accumTex; uniform sampler2D backTex; // 不透明背景或上一帧颜色 void main() { vec4 acc = texture(accumTex, uv); vec3 bgColor = texture(backTex, uv).rgb; float alpha = max(acc.a, 0.0001); vec3 blended = acc.rgb / alpha; // 解出加权后的颜色 gl_FragColor = vec4(mix(bgColor, blended, clamp(alpha, 0.0, 1.0)), 1.0); }看着不难,但实际工程里到处是细节:累积目标必须是浮点纹理,否则累加几层就可能爆掉精度;混合状态要设成加法混合;合成通道要等所有透明物体画完再执行。Babylon.js 里可以创建自定义 RenderTargetTexture,把半透明物体单独渲染到累积纹理,再挂一个后处理做合成。
4.3 在 Babylon.js 里搭一套最小 OIT
Babylon.js 没有开箱即用的“一键 OIT”,但引擎给了足够多的底层能力,可以用 RenderTargetTexture + ShaderMaterial 实现一套最小可用的加权混合 OIT。
大致步骤是这样:
- 创建两张浮点纹理作为累积目标的 color attachment,一张存加权颜色,一张存权重。
- 创建 RenderTargetTexture,把场景里参与 OIT 的半透明网格放到这个渲染目标的 renderList 里。
- 给这些网格指定一个自定义 ShaderMaterial,片段着色器走累积通道,把颜色和权重写入两张浮点纹理,而不是直接输出到屏幕。
- 主场景先渲染全部不透明物体,拿到一张背景颜色纹理。
- 最后用一个全屏四边形后处理,把背景和累积纹理做合成。
实际项目中,我发现 OIT 最合适的场景是烟雾、火焰、粒子这类没有清晰轮廓的半透明特效。它们本来就没有明确边界,排序错了也看不太出来,但如果能把排序彻底取消,画面稳定度会提升非常多。至于玻璃、水这类需要明确高光和反射的材质,纯 OIT 反而容易让高光权重被稀释,需要给权重函数单独调参,不能照抄烟雾那套。
4.4 深度剥离是质量天花板,但要付出代价
除了加权混合,另一种常被提起的 OIT 是深度剥离。它的思路是:把场景按深度一层层剥离,先找最靠近相机的一层半透明面,画掉;再找第二层,画掉;以此类推。每一层都能保留完整的颜色细节,画面质量确实好,但需要的渲染次数基本等于半透明层数,场景复杂时性能成本不可接受。
在 Babylon.js 里如果要走深度剥离,通常会把半透明物体渲染到多个 RenderTarget,每多一层就多一次全场景绘制。我做过一个试水项目,场景里有三层半透明玻璃,两层剥离就能看,效果确实比加权混合干净,但移动端直接掉到 20fps,根本没法用。如果只是做 PC 端的高端数字孪生、建筑可视化,深度剥离是可以考虑的;如果是 Web 端通用展示,我还是老老实实推荐加权混合。
5. 调试与性能:实战中的排查清单
5.1 症状对照表
我在多个项目里积累了一份透明渲染问题速查表,照着查要比盲调快不少:
| 现象 | 常见原因 | 推荐手段 |
|---|---|---|
| 半透明物体穿过不透明墙体 | 透明物体未做深度测试 | 开启深度预渲染 |
| 两个半透明物体穿插时顺序翻转 | 排序点不准 | 合并网格、减少透明 mesh 数量 |
| 透明物体内部糊成一片 | 三角形层级没有排序 | 考虑 OIT 加权混合 |
| 叶片边缘闪烁、细线 | alphaTest 深度偏差 | 边缘过渡带、调整裁剪阈值 |
| 粒子或烟雾大面积变暗 | 权重函数过小 | 加大近处权重,限制最大权重 |
| 透明面背面消失 | 背面剔除开启 | 设置 backFaceCulling = false |
| 半透明物体自己挡自己出现洞 | 透明材质错误开启深度预通道 | 关闭该材质的 enableDepthPrePass |
5.2 排查顺序建议
遇到透明渲染异常,我先按这个顺序查,能省一半精力。第一,确认材质 transparencyMode 是不是符合预期,尤其是 alphaTest 和 ALPHABLEND 有没有搞混。第二,看场景里半透明 mesh 数量,数量越多排序风险越大,能合并先合并。第三,给所有和半透明相交的不透明材质开深度预渲染,看看问题是否解决。第四,如果还有问题,再上 OIT 把透明层之间的排序去掉。最后才去微调权重函数和混合系数。
千万别一上来就怀疑引擎 bug,九成透明错乱都是排序和深度配置的锅。Babylon.js 的静态场景极少出现引擎级渲染错误,把材质配置和渲染顺序捋清楚,问题基本都能定位。
5.3 两个容易忽略的隐藏坑
第一个是渲染目标尺寸。OIT 的累积纹理如果和主屏幕分辨率不一致,合成时很容易出现边缘模糊或色偏。要动态适配相机宽高比,并在窗口 resize 时重新调整渲染目标尺寸。第二个是透明度排序与 instanced mesh。Babylon.js 的实例化网格共享同一个材质,但每个实例的位置不同,引擎排序时如果只按第一个实例算,后面的实例全都会跟着错。这种场景最好把实例拆成几个部分,或者对关键实例单独设置排序偏移。
最后再分享一个我自己一直在用的习惯:把调试面板里加一个“透明模式总开关”,一键切换场景里所有 ALPHABLEND 材质和深度预通道的组合状态。排查时来回切换对比截图,远比改一行看一帧高效得多。透明渲染这门手艺,靠的就是把原理吃透,再把这些经验沉淀成一套自己的排查流程。