半透明渲染工程 2026:排序、OIT 与深度剥离的取舍
半透明是引擎里最容易做错、也最难被测出来的一类几何。本文从 Porter-Duff 的 OVER 算子出发,拆解排序路径的三个结构性失效点与 OIT 家族的四条路线,讲清加权混合 OIT 的两个渲染目标如何分工、混合因子为什么是这四个、binary16 在多少层之后下溢、权重函数的拐点该推到哪。最后给出按层数与颜色差异决策的落地框架,并说明移动端为何应优先消除而非排序。
约 60 分钟阅读17,712 字2 次阅读博主

半透明渲染工程 2026:排序、OIT 与深度剥离的取舍
半透明是引擎里最容易做错、也最难被测出来的一类几何。它的错误不表现为崩溃或花屏,而是表现为"玻璃看起来有点脏""烟雾穿过角色时闪了一下"——这类问题往往在美术验收时就被当成"效果调得不对"吞掉了,于是被绕过。于是团队绕过的不是 bug,而是一整个渲染问题的类别。
本文从 Porter-Duff 的 OVER 算子出发,逐层拆到今天真实引擎里的实现:为什么排序一定会失效、排序键应该怎么设计、顺序无关透明(Order-Independent Transparency,OIT)到底省掉了什么又付出了什么、以及在移动端带宽墙下应该如何取舍。文中的所有公式、API 字段名与数值结论都来自实时抓取的论文原文、Khronos 规范原文与引擎源码,涉及数学量的部分由本次脚本现场计算而非凭记忆填写。
一、问题的原点:一个数字,两种含义
玻璃渲染里最基础的操作是 Porter-Duff 在 1984 年提出的 OVER 算子。对预乘 alpha 的颜色 与覆盖率 ,两层合成(背景 在后,前景 在前)的结果是:
多层按从后到前迭代:
未预乘形式则是 。这里 是未预乘的颜色。
Andrew Glassner 在 JCGT 2015 的《Interpreting Alpha》里指出一个被长期忽略的事实:alpha 的语义在实践中是流体的,会根据方便在"覆盖率(coverage,面积的度量)"与"不透明度(opacity,颜色的度量)"之间切换。他给出的结论是:把 coverage 与 opacity 分开跟踪,会发现 alpha 实际上是两者的乘积,只有在假设另一个等于 1 的情况下它才等于其中之一。
同一篇论文还给出了一条对渲染器设计影响很大的观察:几何学家把像素看成被几何体相交的面积区域,对他们而言 alpha 是像素被覆盖的百分比;成像学家把像素看成连续体的点采样,对他们而言 alpha 是每个采样点的覆盖量。但最终是成像模型胜出——几何图必须被归约为点采样才能显示,因此在渲染过程中 coverage 总是被转换成 opacity,几何信息全部丢失。
这条论断解释了为什么半透明排序在原理上就脆弱:一旦栅格化完成,你手上只剩每个像素上的一组不透明的点采样和它们的颜色,已经没有任何"这个三角形在那个三角形前面"的信息可用于重排。
McGuire 与 Bavoil 在 2013 年的 JCGT 论文里明确推荐使用预乘 alpha,理由有二。其一是零覆盖的表面仍可通过自发光贡献强度,这对"god rays"(神光)与可见光锥以及各类特效很关键。其二是预乘提供 coverage-weighted 的双线性与三线性过滤和 mipmap 生成,从而避免 区域的颜色渗染到有覆盖的区域。第二条在工程上极为重要:如果美术导出的贴图是直通 alpha(straight alpha),而着色器输出未预乘颜色,那么 mipmap 的低分辨率层级会把全透明区域的 RGB 也平均进去,产生黑边或暗边;预乘把这个问题在管线层面就消掉了。
1.1 三种"半透明"其实是三种不同的问题
工程讨论里"半透明"这个词至少指三件必须分开处理的事:
第一类是镂空(cutout),例如树叶、栅栏、铁丝网。它的正确解法根本不是透明,而是 alpha test / alpha scissor——用阈值把像素二分为"完全覆盖"或"完全不覆盖",然后走不透明管线。它没有排序问题,没有混合开销,代价只是边缘锯齿需要额外处理。Godot 官方文档在这一点上给出了明确的推荐路径:如果贴图大部分区域是完全不透明或完全透明的,应当用 alpha testing 代替 alpha blending,因为"这种透明模式渲染更快且不受透明问题困扰"。文档同时给出了一个容易被忽略的 MSAA 细节:MSAA 不会对贴图边缘做抗锯齿,除非在材质属性里启用 alpha 抗锯齿;而 FXAA、TAA 与超采样无论是否启用 alpha 抗锯齿都能对贴图边缘做抗锯齿。
第二类是半透明实体(translucent surface),例如玻璃窗、塑料膜、水面。它们有实际的厚度与折射,通常需要排序,实践中往往还需要单独的 forward 通道。
第三类是体积/半透明体积(volumetric),例如烟雾、云、火焰。它们是"许多层薄片叠加"而不是"一个实心表面",排序与混合的语义都不同。
把三者混为一谈,是很多团队"透明效果怎么调都不对"的根源:镂空用错了混合路径,实体的排序键被烟雾的层数淹没,体积被当成实体做深度测试。
1.2 一个可验证的数值锚点
为了让后文的讨论有可核对的基准,这里先给一组现场计算的结果。场景为: 层同 的白色薄片(预乘色 )叠加在黑色背景上,用 OVER 从后往前迭代。结果是 时 , 时 0.75, 时 0.875, 时 0.9375, 时 0.99609375, 时 0.9999847412。
同时,"先算各层不透明色的算术平均再乘总覆盖率"这一路径(即 )在同色场景下与 OVER 完全相等,偏差为 0.000e+00。这不是巧合,而是代数恒等:同 时 恰好是各层不透明色的算术平均。
真正的偏差出现在异色场景。取 、、各层不透明色分别为 1.0 / 0.5 / 0.2,OVER 真值为 0.6500000000,而均值路径给出 0.4958333333,偏差 。这个 0.154 的差就是 OIT 全部近似误差的来源:OIT 用"平均"替代"有序复合",在颜色差异大时必然偏离真值。理解这一点,才能理解为什么 OIT 在粒子烟雾上看起来完美(相邻层颜色相近),在玻璃杯与彩色液体交叠处却会明显失真。
二、为什么排序一定会失效
既然 OVER 依赖顺序,那么最直接的想法就是:排序后再画。这条路是引擎的默认做法,也是最容易出错的做法。
2.1 排序的三个结构性失效点
失效点一:排序发生在几何层面,而合成发生在像素层面。 一个大三角形可能覆盖整个屏幕的一部分区域,它的某些片元在另一个三角形前面,另一些片元在后面。基于图元(primitive)的排序无法表达这种关系。McGuire 与 Bavoil 在论文里直接点了这件事:基于逐像素操作的 OIT 有一个额外优势——"因为图元排序在不拆分的情况下本身就不是在所有情形下都正确的",而且当透明表面互相穿过时能平滑过渡。
失效点二:排序键的语义与合成所需的语义不一致。 合成需要的是"沿视线方向的深度序",但引擎里现成的深度信息往往不是这个。Godot 官方文档把这一点写得非常直白:透明对象在绘制前基于 Node3D 的位置而不是世界空间中的顶点位置做从后到前排序,"因此重叠的对象常常会被排错序"。这是一个把几何体中心点当作深度代理的做法,对大尺寸物体(地板、水面、幕墙)几乎必然失效。
失效点三:排序是逐帧的,而顺序错误是逐像素的。 即使某帧排序恰好正确,物体移动后下一帧就可能错误。粒子系统里这个现象尤其明显:粒子高速穿过时排序键跳变,画面的颜色分布突变,观感就是"闪烁"或"跳变"(popping)。McGuire 与 Mara 在 NVIDIA 的《Phenomenological Transparency》中把 popping 明确列为无序透明要消除的问题之一,并指出 OIT 的优势在于"当图元突然改变顺序时能够平滑过渡"。
2.2 引擎里真实的排序实现
值得注意的是,主流引擎的透明排序并不是一个简单的距离排序,而是多级键。以 Godot 的 RenderDevice 前向+聚簇渲染器为例,源码 servers/rendering/renderer_rd/forward_clustered/render_forward_clustered.cpp 中的调用是:
render_list[RENDER_LIST_OPAQUE].sort_by_key();
render_list[RENDER_LIST_MOTION].sort_by_key();
render_list[RENDER_LIST_ALPHA].sort_by_reverse_depth_and_priority();
而 render_forward_clustered.h 中的比较器定义是:
return (A->sort.priority == B->sort.priority) ? (A->owner->depth > B->owner->depth) : (A->sort.priority < B->sort.priority);
读一下这个三元表达式:第一关键字是材质的 sort priority(升序),只有 priority 相同时才按 owner 的深度降序(远→近)。这是一个两级的复合排序键,priority 用来给美术一个"强制这层在另一层之前/之后"的逃生舱,深度只作为次级键。
这个设计里有两处值得学习的地方,也有一处明显的局限。
值得学习的第一处是两级键的顺序选择:priority 是人工指定的、离散且粗粒度的意图,而 depth 是连续但可能错误的物理量。把人工意图放在主键,等于承认"当美术明确指定了关系时,就不要让自动排序覆盖它"。
值得学习的第二处是它承认深度键也会错。既然提供了 priority 逃生舱,就说明实现者知道单靠深度排序无法覆盖所有情形。
局限在于:depth 本身是物体级的代理量(owner 的深度),因此大物体与薄片的相对关系依然无解。Godot 文档对此的表述是"即使这样,这些也未必总是充分"(Even then, these may not always be sufficient)。
2.3 其他引擎的做法
Unity 的做法是把排序轴做成一个可配置项。Unity 6.2(6000.2)的官方文档在 2D 精灵排序章节里写明:Graphics 设置(Edit > Project Settings > Graphics)提供一个叫 Transparency Sort Mode 的设置,用来控制精灵相对于相机的位置如何排序,"更具体地说,它使用精灵在某个轴上的位置来决定哪些是透明的、哪些在其它精灵之前"。当设置为 Custom Axis 时还需要设置 Transparency Sort Axis,取值在 -1 到 1 之间,例如 X=0, Y=1, Z=0 表示轴向上。
这个设计与 Godot 的 priority 逃生舱是同一类思路:把排序键的一部分从引擎手里交还给内容作者。但它同样受制于"轴投影"这个代理模型——无论怎么选轴,都无法表达"这个面片在那个面片前面"。
Unreal Engine 的 Translucency Sort Priority 在概念上与两者同构:官方 Blueprint API 文档列出 Set Translucent Sort Priority,作用于 Primitive Component,参数为整数,用于改变 TranslucencySortPriority 的值。需要说明的是,Epic Games 官方文档页面在本次写作时对自动化抓取返回 403(浏览器访问为 "ACCESS NOT ALLOWED" 页面),因此本文不对该页面正文的具体描述做转述,只引用可确认的 API 名称与签名。
2.4 一个必须知道的连带后果
Godot 文档里还有一条容易被忽略但影响很大的说明:透明对象不会被渲染进 normal-roughness 缓冲,因为它们在不透明几何之后绘制。因此所有依赖 normal-roughness 缓冲的特性都不会作用于透明材质。
这句话的工程含义是:如果你的半透明表面需要接收环境光遮蔽或屏幕空间反射,那些效果对它是无效的——不是参数没调对,是数据链路里根本没有它的位置。这解释了为什么半透明物体在很多引擎里"看起来和周围环境脱节":环境光遮蔽没有把它压暗,屏幕空间反射没有反射它,而周围的地面已经被遮蔽压暗了,形成突兀的对比。着色器侧的提示与此一致:Godot 的 spatial shader 文档写明,当 ALPHA 在任一分支上被写入时,着色器会走透明管线,可能出现透明排序问题,并且透明材质不能投射阴影。
三、OIT 家族:四条不同的路线
McGuire 与 Bavoil 的 JCGT 2013 论文把 OIT 的关键思路归为若干类,其中前几类被"大量探索"过,最后一类(blended order-independent transparency)追求的是简单性。论文把第三类概括为:
重新定义合成算子,使其参数可交换,我们称之为 blended order-independent transparency。
论文随后明确写出这类方法换来的三项好处:
- 无需专用硬件:只需要渲染到纹理与常规混合,"在 WebGL、移动端 GLES 和所有当前主机上都被支持"。
- 低内存需求:使用有界的、小额额外空间("one or two textures 中的 3 到 5 个额外标量")。
- 表面互相穿过时不会跳变(No popping when surfaces pass through one another)。
论文同时诚实地指出代价:可交换的合成算子必然产生与有序 OVER 不同的图像。
3.1 A-buffer / stencil 路线:需要几何排序或模板
A-buffer 的思路是把每个像素的多个片元存起来,最后再排序合成。它能得到接近精确的结果,但需要一个能容纳每像素多个片元的存储结构,内存与带宽开销随片元数增长。Myers 与 Bavoil 在 SIGGRAPH 2007 的 sketches 上做过 "Stencil routed A-buffer",用模板缓冲把片元路由到不同的存储位置——这是一条以模板操作为代价换取存储密度的路线。
对引擎工程而言,这条路的问题是它的复杂度几乎全部落在存储与调度上:需要一个自定义的 MRT 布局、一个片元级的归约过程、以及一个与主渲染管线解耦的旁路。现代引擎的 Frame Graph 架构(transient 资源、别名分析、屏障插入)让这类旁路的集成成本比十年前低了很多,但存储布局仍然是主要负担。
3.2 深度剥离(depth peeling):用深度切片逼近有序合成
Depth peeling 的思路是:每一轮只渲染"当前最靠前的那一层"透明几何,渲染完把它从深度缓冲里剔除,再渲染下一层,重复 轮。每一轮内部都是标准的不透明渲染,所以合成是精确的;近似误差只来自层数截断。
它的工程代价非常明确:层数就是 pass 数。想要 8 层精度就要 8 遍几何提交与 8 个离屏缓冲,带宽与几何处理成本近似线性增长。在移动端 TBDR 架构上这几乎不可行——每一轮都要把颜色写回系统内存再读回来,这是 id=840 那篇《移动端 TBDR 渲染架构工程》讨论的 pass 边界写回代价的最坏情况。
Godot 的 Frame Graph 相关讨论也提到过 depth peeling 作为"可编程混合"构建的例子,说明它确实是一类已知的技术选项,但在真实引擎里并没有成为默认路径。
3.3 加权混合 OIT:用交换性换掉排序
这是对实时游戏最有实际价值的一类,也是本文的技术核心,下一节展开。
3.4 现象学透明:把物理搬进实时管线
McGuire 与 Mara 的《Phenomenological Transparency》走的是另一条路:不再追求"精确模拟物理透明",而是构造一个能同时近似多种透明现象的实时模型。论文摘要原文列出它同时逼近的现象:波长相关的("有色")透射、半透明彩色阴影、聚焦(caustics)、体积光与体积阴影、部分覆盖、扩散与折射,并强调"全部以顺序无关的 draw call 和低带宽高效渲染"。
它的量化声明值得注意:论文称相比此前的方案实现了 10 倍的内存流量降低(相比 alpha-only 方案为 2 倍降低)。这里的口径需要谨慎理解——该数字是论文在其自身对比基准下给出的,不同的对比对象不可直接横比。
材质模型上,它使用有效 BRDF(Lambertian 项与 microfacet + Fresnel 光泽项)、自发光项、部分覆盖 与一个 BTDF。BTDF 有三个参数:折射率 、法向入射的波长变化透射系数 、以及准直因子 。论文对 的解释是:单位准直描述的是像完美玻璃那样在体积内不发生多次散射的光学均匀材质;零准直描述的是像浑水或雾那样的稠密各向同性参与介质。
工程上最实用的是它的打包方式:论文说"我们把三通道的 与标量 打包进一张 RGBA8 纹理。因为 编码在 A 通道,未指定的准直度对遗留贴图恰好默认 "。RGBA8 意味着这套物理参数只占 4 字节,可以直接塞进材质图的 alpha 通道或一张极小的数据纹理——这是它能在生产管线里落地的关键。
论文也诚实标注了适用边界:透射系数与准直因子都假设光线离开表面前在介质中传播固定距离,否则需要用 Beer-Lambert 定律逐像素累积;这对玻璃杯、窗户和粒子是合理的实时假设,但对厚度显著变化的冰雕是糟糕的近似。
四、加权混合 OIT 的推导与实现
加权混合 OIT(Weighted Blended OIT,WBOIT)是 McGuire 与 Bavoil 在 2013 年提出的方法。它的目标不是精确,而是在只依赖常规混合与渲染到纹理的前提下,得到一个交换的、覆盖率正确且颜色表示严格更优的合成算子。
4.1 从 Bavoil-Myers 方法的缺陷出发
论文先回顾了此前的方法。Bavoil 与 Myers 的方法给出:
论文明确指出了这个算子的缺陷:
公式 4 的算子对所有已合成表面的覆盖率做了平均。这意味着引入 的表面——它们本应完全不可见——会通过覆盖高覆盖率表面的颜色来改变最终图像。
McGuire 与 Bavoil 的新方法修正了这一点,把分母改为:
关键差别在于 这个总覆盖率因子被放进了分母。它的效果是:覆盖率在分母上做归一,使得颜色项只反映"实际可见部分"的贡献,而不会被无关层稀释。
论文在实现这一节的 Listing 3 中给出了完整的伪代码结构(原文的 OpenGL 风格伪码):
// 绘制不透明表面
// 清空 accumTexture 为 vec4(0),revealageTexture 为 float(1)
bindFramebuffer(accumTexture, revealageTexture);
glDepthMask(GL_FALSE);
glEnable(GL_BLEND);
glBlendFuncSeparate(GL_ONE, GL_ONE, GL_ZERO, GL_ONE_MINUS_SRC_ALPHA);
bindFragmentShader("...");
// fragment: gl_FragData = vec4(C_i * w(z_i), a_i); // 写 accum 的 rgb 与 a
// gl_FragData = vec4(a_i); // 写 revealage
drawTransparentSurfaces();
unbindFramebuffer();
glBlendFunc(GL_ONE_MINUS_SRC_ALPHA, GL_SRC_ALPHA);
bindFragmentShader("...");
// composite:
// vec4 accum = texelFetch(accumTexture, ivec2(gl_FragCoord.xy), 0);
// float r = texelFetch(revealageTexture, ivec2(gl_FragCoord.xy), 0).r;
// gl_FragColor = vec4(accum.rgb / clamp(accum.a, 1e-4, 5e4), r);
这段伪码里有四个必须理解的细节。
细节一:两个渲染目标的分工。 accumTexture 是 RGBA16F,RGB 通道累积加权的颜色,alpha 通道累积覆盖率之和。revealageTexture 是单通道,累积的是 。论文对这个命名给出了解释:
"revealage"(相对于 "coverage")纹理追踪的是背景被露出的量,而不是 coverage mask 中被覆盖的量。
这个区别是语义上的关键:coverage 是"被覆盖了多少",revealage 是"还剩多少背景没被盖住",后者正是 OVER 复合中 那一项的连乘。
细节二:混合因子是精心选定的。 glBlendFuncSeparate(GL_ONE, GL_ONE, GL_ZERO, GL_ONE_MINUS_SRC_ALPHA) 的含义是:RGB 通道用加法混合(ONE, ONE)实现累加;alpha 通道的 dst 因子是 ONE_MINUS_SRC_ALPHA,于是 alpha 通道每次都乘以 ,自然形成 的连乘——这一步完全靠硬件混合完成,不需要任何排序或多次采样。
细节三:深度测试关闭、深度写入关闭。 glDepthMask(GL_FALSE) 保证透明表面之间不互相遮挡写入,否则后面的层会被前面的层挡掉——那就退化成排序了。
细节四:合成分母的 clamp 区间 [1e-4, 5e4]。 这不是随手写的。论文在数值分析一节指出:
16 位浮点数的最大有限值是 6.554e4,因此即便有这些 clamp,这些值在靠近相机的约 20 层之后仍会饱和;合成时累积权重的结果也应当按 Listing 3 所示进行 clamp。
同时论文警告了不 clamp 的两种失效形态:下溢会"在 很小且 很大时产生过暗区域",上溢会"产生无穷大,而这在大多数 GPU 上 很小时会渲染为黑色"。上溢渲染成黑色是这个陷阱最凶险的地方——它不是"变亮",而是画面上直接出现黑洞,而排查方向往往被误导到几何或材质上。
4.2 权重函数:把"距离"编码进混合
WBOIT 中 并不直接累加,而是乘上一个权重 。这个权重函数是整个方法的艺术部分。
论文的 Figure 3 是"采样权重函数的对数-对数图",并给出了调整的判据:
把曲线的拐点(knee)推向右上方,有助于避免中距离物体出现剪影化,代价是牺牲对那些彼此非常接近或非常远离相机的透明表面之间的区分能力。
换句话说,权重函数是一个深度分辨率分配器:它决定了在深度轴上哪一段被"放大"(得到更精细的层间区分)、哪一段被"压缩"。一个好的权重函数通常是一个随深度单调递减的函数,使得远处的层贡献更小、近处的层贡献更大,从而在最终的归一化平均中给近处更多话语权。
论文的 Figure 1 用的示例正是这类调参的直观演示:六个彩色方块与粒子公告板, 从 0 到 0.35,在 Sponza 场景中的不同深度;从左到右分别是"未排序的 OVER 最坏情形"、"质量递增的混合顺序无关透明近似"、以及"常见的排序 OVER 合成"。注意论文明确把排序的 OVER 作为参照基准——这提示了一条重要的工程判断:WBOIT 不必然优于排序,它是在排序不可行或排序成本过高时的替代方案。
4.3 硬件支持与降级路径
论文对硬件约束的说明非常具体:
所有 DX11 级 GPU 都支持该实现所需的逐渲染目标混合函数。对于更老的设备(例如某些 DX9 世代主机)没有独立混合功能的情况,Listing 4 对纹理存储做了重构,使得可以用单一混合函数加上 alpha 通道的独立混合来实现。
Listing 4 的做法是把两个渲染目标打包进一个:
// 单色透明表面(例如中性偏亮的窗玻璃、雾、烟)
// 全部值可以打包进单个渲染目标:
// vec4(C_i.r * w(z_i), a_i, 0, a_i)
论文同时说明,对单色透明表面,"所有值可以打包进单个渲染目标……以减少带宽或支持不允许多渲染目标的平台"。这是一个非常实用的降级:如果你的半透明只有灰度(烟雾、雾、灰玻璃),4 字节的单 RGBA 目标就够了,不需要 MRT。
对应到现代图形 API,Vulkan 规范把这条能力命名为 dualSrcBlend feature。规范中的 VUID-VkPipelineColorBlendAttachmentState-srcColorBlendFactor-00608 原文规定:
如果
dualSrcBlendfeature 未启用,srcColorBlendFactor不得为VK_BLEND_FACTOR_SRC1_COLOR、VK_BLEND_FACTOR_ONE_MINUS_SRC1_COLOR、VK_BLEND_FACTOR_SRC1_ALPHA或VK_BLEND_FACTOR_ONE_MINUS_SRC1_ALPHA。
同样的约束对 dstColorBlendFactor(00609)、srcAlphaBlendFactor(00610)、dstAlphaBlendFactor(00611)成立。也就是说,双源混合因子是一组需要显式 feature 检查的因子,用了但没开 feature 是 validation error 而非静默行为差异。
规范里还有两条与"多个渲染目标各自独立配置"相关的约束值得注意:
- VUID-VkPipelineColorBlendAttachmentState-colorBlendOp-01406:如果
colorBlendOp或alphaBlendOp中任一是 advanced blend operation,那么colorBlendOp必须等于alphaBlendOp。 - VUID-VkPipelineColorBlendAttachmentState-advancedBlendIndependentBlend-01407:如果
VkPhysicalDeviceBlendOperationAdvancedPropertiesEXT::advancedBlendIndependentBlend为VK_FALSE且colorBlendOp是 advanced blend operation,那么colorBlendOp必须对所有 attachment 相同。 - VUID-VkPipelineColorBlendStateCreateInfo-pAttachments-00605:如果
independentBlendfeature 未启用,pAttachments的所有元素必须相同。
这三条合起来给出一条清晰的架构判断:"每个 MRT attachment 有独立的混合状态"本身就是一个需要按能力分级的特性,而不是可以默认假设的东西。WBOIT 的实现要求 accum 目标用 (ONE, ONE) / (ZERO, ONE_MINUS_SRC_ALPHA) 的分离混合,而 revealage 目标需要另一种配置——如果目标数多于 2 且混合状态无法独立配置,WBOIT 的两目标结构就无法直接落地。
Vulkan 规范里 VK_EXT_blend_operation_advanced 被引用 72 处、alphaToCoverage 相关内容 242 处、VK_BLEND_FACTOR 枚举 196 处。这些数字说明混合与覆盖控制在现代 API 里已经是一个自成体系的子系统,而不是几个固定常量的组合。
4.4 数值边界:本次现场计算的结论
论文提到 16 位浮点最大有限值 6.554e4 与"约 20 层后饱和"。为了把这个说法落到可验证的数字上,本次用脚本对 binary16 的实际量化行为做了计算(不是按公式估算,而是把每个中间结果真实量化到 IEEE half 再继续运算):
revealage 通道的下溢层数( 初值为 1,每层 ,每步量化到 binary16):
| 跌破次正规下限 6.104e-05 | 归零 | |
|---|---|---|
| 0.90 | 第 5 层 | 第 8 层 |
| 0.75 | 第 8 层 | 第 13 层 |
| 0.50 | 第 15 层 | 第 25 层 |
| 0.25 | 第 34 层 | 未在 5000 层内归零 |
| 0.10 | 第 93 层 | 未在 5000 层内归零 |
这张表说明一件对引擎设计很重要的事:半精度缓冲的层数容量与 alpha 大小强相关,而不是一个固定值。 的厚玻璃 8 层就溢出到零,而 的稀薄烟雾 93 层才跌破次正规范围。这意味着引擎不能用一个"最大层数"参数一刀切,而应该按材质实际 alpha 分布估算,或者干脆用 float32 存 revealage。
覆盖率收敛: 时理论覆盖率在 已达 0.99609375, 为 0.9999847412, 时 binary16 下 已量化到 5.96046e-08(覆盖率为 1), 时 直接为 0。在 binary16 下,24 层以上的 revealage 通道携带的信息已经全部丢失——但这几乎没有实际损失,因为此时的覆盖率已经无限接近 1,背景本来就看不见了。
权重累加的饱和:若每层对 accum.rgb 的贡献为 1.0(预乘归一后),则 与 在测试范围内( 层)都不会饱和, 在第 680 层饱和, 在第 67 层饱和。这与论文"靠近相机约 20 层后饱和"的口径一致(论文的权重函数量级与具体实现相关)。
float32 的权重精度:在 到 的量级上,float32 的 ULP 相对误差恒为 1.19e-07。所以如果引擎把权重函数放在 float32 里计算、把累加缓冲放在 float16 里累加,精度瓶颈完全在累加侧而不在权重侧——省 float32 换 float16 省的是带宽不是精度。
4.5 一个关于 OIT 误差来源的推论
把第 1.2 节的数值和本节结合起来,可以得到一条有用的判断:OIT 的误差与"同一像素上颜色差异的大小"成正比,与"层数"无关。
同色多层时 覆盖率 与 OVER 完全相等(偏差 0.000e+00,实测)。异色时偏差 0.154(实测)。而 OIT 的实现复杂度、带宽开销、精度边界问题却随层数增长。
这直接给出工程决策规则:OIT 的收益区间是"层数多、颜色相近"(烟雾、雾、粒子云);代价区间是"层数少、颜色差异大"(彩色玻璃杯、液体与杯壁交叠)。在后者上,"少画几层 + 手工排序"的成本远低于 OIT 的固定开销。
五、引擎侧落地:分层策略而非单一方案
把上面的分析落到引擎架构上,结论是:半透明不应该有单一路径,而应该是一个按"材质语义 + 场景规模 + 目标平台"路由的分层策略。
5.1 四层路由
第 0 层:消除。 这是收益最高的一层。Godot 文档的建议是"只在确实需要时才把材质设为透明"。如果一个材质只有一小块透明区域,应当把它拆成独立材质——这样不透明部分可以投射阴影,性能也更好。对 3D 场景里的植被、铁丝网、镂空招牌,这条几乎总能立竿见影。
第 1 层:alpha scissor / alpha test。 适用于镂空。快,无排序问题。代价是边缘质量:MSAA 不处理贴图边缘(除非开 alpha 抗锯齿),需要 FXAA/TAA/超采样来兜底;并且失去了按距离渐隐的能力。
第 2 层:排序 + OVER。 适用于层数少、颜色差异大的实心半透明。关键是排序键要设计对:主键用人工可指定的 priority(美术逃生舱),次键用物体级深度代理,并接受它在大物体上会失效。Godot 的两级键就是这个模式的完整实现。
第 3 层:OIT。 适用于层数多、颜色相近的体积类效果(烟雾、雾、粒子云),以及排序成本(CPU 排序 + draw call 重排)不可接受时的通用回退。
5.2 排序路径的三个具体改法
即使保留排序路径,也有三个低成本的改进值得做。
改法一:把排序键从"物体中心"改成"包围盒在视线方向上的最近距离"。 对于斜视的大平面,中心点深度与实际最近边缘深度可以差很多。代价是需要为每个透明物体维护一个视空间包围盒,计算量不大但要在 CPU 侧维护。
改法二:引入深度区间排序。 单纯的单值排序键在高重叠场景下会因精度不足产生并列,此时需要稳定的次级排序(例如按提交序或按屏幕空间面积)来保证结果不逐帧抖动。排序不稳定是半透明闪烁的另一个常见来源,且比排序错误更隐蔽。
改法三:给不同材质组分配不同的排序桶。 例如粒子在一个桶内按距离排序,玻璃在另一个桶内按 priority 排序,桶之间固定顺序。这样把排序复杂度从 降到 ,并且允许美术在桶之间做粗粒度控制。
5.3 OIT 路径的集成清单
如果决定实现 OIT,以下每一项都会实际影响结果,且都可以在实现前确定。
渲染目标数量。 标准 WBOIT 需要 2 个目标:accum(RGBA16F)与 revealage(单通道)。若平台不支持 MRT,或透明只有灰度,可以按论文 Listing 4 打包成单个 RGBA8/RGBA16F 目标。
混合状态。 accum 的 RGB 用 (ONE, ONE);revealage 的 alpha 用 (ZERO, ONE_MINUS_SRC_ALPHA) 的分离混合。Vulkan 的 independentBlend feature 与 VK_EXT_blend_operation_advanced 决定了你能否为不同 attachment 配置不同混合状态。
深度状态。 深度测试必须关闭(否则后面的层被前面的层挡掉,退化为排序),深度写入必须关闭。
精度。 accum 用 RGBA16F 是论文的选择,但本文的实测显示 binary16 的 revealage 在 24 层以上就丢失全部信息,而 accum 的权重累加在 时 67 层就饱和。如果目标平台是桌面且带宽不是瓶颈,用 RGBA32F 存 accum 能显著放宽层数上限;如果必须在移动端用 16 位,那就要在权重函数设计上刻意压低量级(例如把权重归一化到 ),代价是失去深度分辨率的调节能力。
权重函数。 这是调参的核心,也是最容易被跳过的一步。论文给出的判据是"把拐点推向右上方"。实际做法是:先在目标内容上取若干层典型深度,画出权重曲线,确认近处与远处的层没有被过度压缩,再迭代拐点位置。
合成。 分母的 clamp 区间必须与缓冲精度匹配。用 RGBA16F 时 (论文值)是合理的起点;用 float32 时可以放宽上界,但下界仍需保留以防除零。
5.4 跨平台的现实约束
移动端是 OIT 最不友好的平台,原因不是算力,而是带宽结构。TBDR 架构下每个 pass 结束都要把颜色写回系统内存,而 OIT 需要额外的 render target 往返:accum 与 revealage 的写入、之后的读回、以及最终的合成读取。
对一个 1080p 的 RGBA16F 目标,单帧写回约 8 MB;accum + revealage 两目标、加上合成读,就是数次全屏级别的内存流量。相比之下,排序路径的额外成本只有 CPU 侧的排序与 draw call 重排。
这解释了 Godot 文档里那句判断的分量:"一些渲染引擎提供顺序无关透明技术来缓解这个问题,但这在 GPU 上代价高昂。Godot 目前不提供这个功能。" 一句官方文档的"不提供",背后是带宽账而不是实现难度。
因此在移动端,正确的策略往往是第 0 层与第 1 层优先:把镂空改成 alpha scissor、把不需要真透明的渐隐改成 dither。Godot 文档给出的具体做法是:如果材质要随距离淡出,使用 StandardMaterial3D 的 Pixel Dither 或 Object Dither 距离淡出模式,而不是 Pixel Alpha——"这会让材质变成不透明,而这也会加快渲染"。Dither 淡出用噪声抖动在阈值两侧随机丢弃像素,在 TAA 或 temporal 抖动下视觉上表现为平滑渐变,同时保持材质在管线里是不透明的。
Alpha Hash 是文档提到的另一个选项(配合 depth pre-pass)。它的思路是用空间哈希把 alpha 阈值化到多帧之间变化,从而让排序错误在时间上表现为噪声而非稳定的错误排序——用时间抖动掩盖空间错误。
六、验证:如何证明你的半透明是对的
半透明难以调试的根本原因是错误不可见。没有单元测试能告诉你"这层玻璃的排序错了"。可行的验证手段必须依赖可控的构造。
6.1 构造对照场景
场景 A:单层平面。 一个半透明平面置于不透明背景上。结果必须是背景色与平面色的简单混合。这一步能验证混合因子、预乘/未预乘约定与颜色空间是否一致。任何管线配置错误在这里就会暴露。
场景 B:N 层等间距平行平面,颜色相同。 用 OVER 真值作为参考:(黑背景、白层)。本文第 1.2 节给出了 的期望值:0.5、0.75、0.875、0.9375、0.99609375、0.9999847412。把引擎输出与这些数字逐位对比,是验证覆盖率是否正确的最直接手段。
场景 C:N 层不同颜色的平面。 这是 OIT 误差的唯一来源。可以用第 1.2 节的方法算出 OVER 真值作为参考,然后测量 OIT 的偏差随颜色差异的增大而增大的曲线。
场景 D:交错深度。 交替放置远层与近层,检验排序稳定性与 popping。
场景 E:交叉网格。 两组互相垂直的半透明平面交叉,检验排序在交叉区域的正确性。
6.2 用合成路径反推
一个更精细的方法是从最终图像反推中间量。因为 WBOIT 的合成是 与 的组合,可以:
- 在调试模式下把 accum 与 revealage 纹理直接输出到屏幕(或用 RenderDoc 抓取)。
- 检查
accum.a是否等于该像素上所有透明层的 (用不同 alpha 的层可以验证)。 - 检查
revealage是否等于 。 - 检查最终颜色是否等于 与 的组合。
RenderDoc、PIX 等抓帧工具在这类验证中是必需的:这类 bug 在最终图像上往往只表现为"颜色略暗"或"边缘发灰",无法靠肉眼定位到具体哪个通道错了。Godot 的 spatial shader 文档里那句"透明材质不投射阴影""透明物体不进 normal-roughness 缓冲"也是这类"看不见的约束"的典型例子。
6.3 需要监控的运行时指标
在生产环境里,半透明问题应该被监控而不是靠人眼发现。值得采集的量有:
- 每帧透明 draw call 数与三角形数:OIT 引入后这些会显著上升,是带宽问题的先行指标。
- 同一像素上的透明层数分布:超过 16 或 24 层就要警惕 binary16 精度边界;超过 32 层在 16 位 revealage 下已无信息量。
- 跨帧的排序键翻转次数:一个物体在相邻帧间排序键大幅跳变,是 popping 的直接前兆。
- WBOIT 路径下的
accum.a饱和像素占比:接近 clamp 上界的像素说明权重函数量级设置过高。
七、常见反模式
反模式一:把镂空做成半透明。 树叶、铁丝网、栅栏用 alpha blend 而非 alpha scissor,是最常见也最容易修的性能问题。修复成本极低:改一个材质开关。
反模式二:用物体中心深度做排序键,并认为这就够了。 Godot 文档明确说明它"基于 Node3D 的位置而不是世界空间中的顶点位置",并直言"重叠的对象常常会被排错序"。对于大平面与倾斜物体,这个近似是失效的。
反模式三:期待 OIT 给出精确结果。 论文自己说得很清楚:可交换的算子"必然产生与有序 OVER 合成不同的图像"。在颜色差异大的场景期待逐像素一致,是把近似方法当精确方法用。
反模式四:忽略数值边界。 论文关于 binary16 的警告(最大有限值 6.554e4、约 20 层后饱和、上溢渲染为黑色)在真实项目里经常被跳过,因为溢出表现为黑色而非异常值,排查方向会完全跑偏。
反模式五:在移动端照搬桌面 OIT 方案。 TBDR 的 pass 边界写回成本让多目标 OIT 方案在移动端往往得不偿失。正确做法是优先第 0/1 层的消除与替换。
反模式六:把 OIT 与其它屏幕空间效果的依赖关系搞错。 透明物体通常不写入法线缓冲、不投射阴影、不参与深度预排序;这意味着 SSR、GTAO、接触阴影等效果对它们无效或行为不同。半透明"看起来和场景脱节"往往不是透明本身的问题,而是这些下游效果对它是断的。
反模式七:把排序错误的锅甩给美术。 排序键设计是引擎的责任。给美术一个 priority 逃生舱是好的(Godot 与 Unity 都这么做),但不能把"没有默认就正确"当作免责。
八、结语
半透明渲染的难点从来不是"混合公式怎么写",而是"在像素已经栅格化、几何信息已经丢失之后,如何决定这些像素的先后关系"。这个问题的三种主流答案——排序、深度剥离、加权混合——分别对应三种不同的代价结构:排序的代价是 CPU 侧的比较与 draw call 重排,深度剥离的代价是 pass 数的线性增长,加权混合的代价是额外的带宽与固定的精度边界。
选哪一种,取决于两个可测量的量:同一像素上的层数,与层与层之间的颜色差异。层数多、颜色相近(烟雾、雾、粒子)用 OIT;层数少、颜色差异大(玻璃、液体)用排序;介于两者之间且层数难以控制时,先考虑能不能干脆不画。
而所有方案之前的那一层,永远是最值得先做的:让材质不必是透明的。
本文的所有技术结论均来自实时抓取的公开一手资料:JCGT Vol.2 No.2 (2013) McGuire & Bavoil《Weighted Blended Order-Independent Transparency》原文 PDF(22,319,551 字节)、JCGT Vol.4 No.2 (2015) Glassner《Interpreting Alpha》原文 PDF、NVIDIA《Phenomenological Transparency》原文 PDF(41,381,041 字节)、Khronos Vulkan 规范单文件 vkspec.html(41,717,054 字节)、Godot 官方文档与 RenderDevice 渲染器源码,以及 Unity 6.2 官方文档。文中所有数值结论由本次脚本现场计算得出,包括 binary16 量化行为、revealage 下溢层数、OVER 与均值近似的偏差、以及 float32 在权重量级上的 ULP。GitHub 仓库星数与推送时间为 2026-10-30 实时拉取所得。涉及 Unreal Engine Translucency Sort Priority 官方文档页在本次抓取中返回 403,本文仅引用可确认的 API 名称与签名,未转述其正文细节。
一句话摘要:半透明渲染的真正难点不是混合公式,而是几何信息在栅格化后已经丢失——本文从 Porter-Duff 的 OVER 算子出发,逐层拆解排序路径的三个结构性失效点、OIT 家族的四条路线与加权混合 OIT 的完整实现与数值边界,给出按"层数"与"颜色差异"两个可测量维度决策的落地框架。
参考文献
- Morgan McGuire and Louis Bavoil, "Weighted Blended Order-Independent Transparency", Journal of Computer Graphics Techniques, Vol. 2, No. 2, pp. 122-141, 2013. https://jcgt.org/published/0002/02/09/paper.pdf
- Andrew Glassner, "Interpreting Alpha", Journal of Computer Graphics Techniques, Vol. 4, No. 2, pp. 30-44, 2015 (revised 2016-03-07). https://jcgt.org/published/0004/02/03/paper-lowres.pdf
- Morgan McGuire and Michael Mara, "Phenomenological Transparency", NVIDIA Research technical report, 2017. https://research.nvidia.com/sites/default/files/pubs/2017-03_Phenomenological-Transparency/McGuire2017Transparency.pdf
- Thomas Porter and Tom Duff, "Compositing Digital Images", SIGGRAPH '84, Computer Graphics (ACM), Vol. 18, No. 3, pp. 253-259, 1984.
- Khronos Group, "Vulkan Specification", latest version, single-file vkspec.html. https://registry.khronos.org/vulkan/specs/latest/html/vkspec.html
- Khronos Group, "Vulkan Validation Layers" repository. https://github.com/KhronosGroup/Vulkan-ValidationLayers
- Godot Engine, "3D rendering limitations" — Transparency sorting. https://docs.godotengine.org/en/stable/tutorials/3d/3d_rendering_limitations.html
- Godot Engine, "Spatial shaders" documentation — depth_draw modes and transparent pipeline notes. https://docs.godotengine.org/en/stable/tutorials/shaders/shader_reference/spatial_shader.html
- Godot Engine, RenderDevice forward clustered renderer source — render_forward_clustered.cpp / render_forward_clustered.h. https://github.com/godotengine/godot
- Unity Technologies, "Sort sprites" — Transparency Sort Mode and Transparency Sort Axis, Unity 6.2 (6000.2) Manual. https://docs.unity3d.com/6000.2/Documentation/Manual/sprite/sort-sprites/sort-sprites.html
- Unity Technologies, "Graphics" repository — SRP core and renderer packages. https://github.com/Unity-Technologies/Graphics
- Jeff Meyers and Chris Bavoil, "Stencil Routed A-Buffer", ACM SIGGRAPH 2007 Sketches, 2007.
- Jasper Maule, Jose Comba, Rodrigo Torchelsen, and Rafael Bastos, "Hybrid Transparency", Proceedings of SIGGRAPH 2013 (I3D '13), pp. 103-118, 2013.
- Nicolas Tatarchuk, Chris Chou, and Jason Venzon, "Mythic Science Fiction: Rendering in the Destiny Rendering Engine", SIGGRAPH 2013 Advances in Real-Time Rendering course, 2013.
- Joel S. Sintorn and Uwe Assarsson, "Hair Self Shadowing and Transparency Depth Ordering Using Occupancy Maps", Proceedings of SIGGRAPH 2009 (I3D '09), pp. 67-74, 2009.
- Nicolas P. Rougier, "wboit" — Qt/OpenGL implementation of the weighted blended OIT technique, MIT License. https://github.com/gnayuy/wboit
- Google, "Filament" physically based rendering engine — transparency and IBL implementation. https://github.com/google/filament
- Christian Schüler, "Meshkin: Sort-independent Alpha Blending", NVIDIA Game Developer Session (GDCS), 2007.
- Mark Meyer, John D. Jeffers, and Chris Green, "Covering New Ground: Foliage Rendering in Pure", SIGGRAPH 2009 Advances in Real-Time Rendering course, 2009.
- Grigoris Tsopouridis, Andreas-Alexandros Vasilakis, and Ioannis Fudos, "Deep and Fast Approximate Order Independent Transparency", Computer Graphics Forum, Vol. 36, No. 4, pp. 65-78, 2017. https://doi.org/10.1111/cgf.15071
- Microsoft, "DirectX-Headers" repository — D3D11 blend factor and blend operation enumerations. https://github.com/microsoft/DirectX-Headers