需要预先学习:


1. 透明与混合

玻璃、水面、烟雾、粒子——真实感场景离不开半透明。不透明物体的遮挡由深度缓冲解决,谁近谁显示,与顺序无关(见 深度缓冲);半透明则不同:它的颜色要与背后已经画好的内容按比例叠加,而"按比例叠加"天然依赖顺序。这篇笔记讨论半透明渲染的所有关键问题:混合怎么做、顺序怎么排、什么时候可以不排序、以及不排序的精确方案(OIT)。

1.1 alpha 通道

颜色由 RGBA 四个分量组成,第四个分量 alpha 表示不透明程度,范围 [0,1][0, 1]:

alpha 含义
1 完全不透明,正常覆盖背后内容
0 完全透明,不可见
0 ~ 1 半透明,与背后内容按比例混合

1.2 over 混合方程

半透明合成使用 over 操作符:源颜色(source,即将写入的片元)以自身 alpha 为权重,覆盖目标颜色(destination,颜色缓冲中已有的内容):

Cresult=αs⋅Csrc+(1−αs)⋅CdstC_{result} = \alpha_s \cdot C_{src} + (1 - \alpha_s) \cdot C_{dst}

一堵 α=0.3\alpha = 0.3 的蓝色玻璃墙,最终颜色就是 30% 的玻璃色加 70% 的背景色。

1.3 OpenGL 中的混合

混合发生在深度测试通过之后、颜色写入之前(见 帧缓冲 的写入时机),相关 API:

glEnable(GL_BLEND);                                      // 混合默认关闭
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);       // over 操作符
glBlendEquation(GL_FUNC_ADD);                            // 默认:加法
glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,
                    GL_ONE, GL_ONE_MINUS_SRC_ALPHA);     // RGB 与 A 分开设置

glBlendFunc 指定源与目标的权重因子,glBlendFuncSeparate 允许 RGB 与 alpha 通道用不同因子(alpha 通道自身的正确累积需要单独配置,避免 alpha 被重复加权)。

注:还有一种常见约定是预乘 alpha(premultiplied alpha):纹理存储时颜色已经乘过 alpha,混合用 glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA)。它对颜色插值更友好(透明区域的颜色不再泄漏到边缘),合成图像与粒子系统常用。


2. 绘制顺序:两条规则

over 操作符不满足交换律。红蓝两片 α=0.5\alpha = 0.5 的玻璃,先后不同、结果不同:

先红后蓝:C=0.5B+0.25R+0.25bg\text{先红后蓝:} \quad C = 0.5 B + 0.25 R + 0.25 bg

先蓝后红:C=0.5R+0.25B+0.25bg\text{先蓝后红:} \quad C = 0.5 R + 0.25 B + 0.25 bg

顺序错了,画面就错了。由此得到半透明渲染的两条基本规则。

2.1 规则一:先画不透明,再画半透明

一帧的绘制顺序:

  1. 所有不透明物体:深度测试 + 深度写入照常,绘制顺序任意(近到远更优,见 剔除总结);
  2. 半透明物体:按规则二排序绘制。

为什么必须这个顺序:半透明的 over 需要"背景"已经就位。如果不透明物体还没画,半透明片元混合到的是垃圾颜色;更糟的是不透明物体后画会用深度测试盖掉半透明——玻璃画好了,玻璃后面的墙再画时深度更近(或深度写入冲突),玻璃就被切掉了。

2.2 规则二:半透明从远到近

半透明之间按视距从远到近(back-to-front)绘制:先画远处的,近处的叠在上面。这与画家算法(见 深度缓冲)一个道理——over 的正确合成顺序就是从后往前叠。

2.3 深度写入:关闭

半透明绘制时保留深度测试、关闭深度写入:

glDepthMask(GL_FALSE);    // 只测试,不写入深度
// ... 绘制半透明物体
glDepthMask(GL_TRUE);     // 恢复,供下一帧使用

关闭深度写入的原因:

  • 深度测试仍必要:半透明物体要被不透明物体正确遮挡——墙后的玻璃不该透过墙被看见,这靠深度测试完成;
  • 深度写入必须关:如果玻璃写了深度,比它远的其他半透明(穿过玻璃的烟、玻璃后面的玻璃)深度测试失败,直接消失。半透明本来就不该"遮挡"更远的东西,它只按 alpha 的比例部分遮挡——这个信息记不进只有一个值的深度缓冲,只能靠绘制顺序保证。

深度缓冲是"二元遮挡"的结构:每个像素只记一个最近深度,只回答"挡或不挡"。半透明是"按比例遮挡",与这个结构天然不兼容,这也是半透明一切麻烦的根源。

注:这条规则有例外。多层水面、大面积薄玻璃等"近处的不透明感较强"的场景,有时故意保留深度写入,接受远处的半透明消失,换取性能与深度排序稳定性——一种实用的权宜。


3. 排序怎么做与排不动的时候

规则二要求排序,实践中按粒度分几档:

粒度 做法 适用
逐物体 CPU 按视距排序整个物体 玻璃、水这类数量少的独立物体
逐粒子 / 逐实例 粒子系统内部排序 烟雾、火焰
逐三角形 网格内三角形排序 几乎不做,太贵

逐物体排序是 O(nlog⁡n)O(n \log n) 的 CPU 排序,半透明物体通常不多,开销可以接受。排不动的情况:

  • 大物体重叠:两扇大玻璃交错,物体 A 整体在 B 前、局部在 B 后,任何整体顺序都是错的;
  • 凹形自遮挡:一只玻璃杯,自身前壁与后壁交错,逐物体排序(甚至逐三角形排序)都无法完全正确;
  • 动态交错:粒子与网格穿插。

工程上对这类误差普遍容忍:视觉上不显眼的错误顺序,比付出精确排序的代价划算。真正追求精确时,用后面的 OIT。

3.1 与顺序无关的加法混合

加法混合(additive blending)天生不需要排序:

Cresult=αs⋅Csrc+CdstC_{result} = \alpha_s \cdot C_{src} + C_{dst}

glBlendFunc(GL_SRC_ALPHA, GL_ONE);    // 加法混合

加法满足交换律,谁先谁后结果一样。火焰、光晕、魔法特效这类"只增亮、不遮挡"的发光体都用它,这也是粒子系统大量使用加法混合的原因。


4. Alpha Test:不需要排序的"透明"

树叶、铁丝网、栅栏这类镂空纹理(cutout)的像素要么完全透明、要么完全不透明,没有中间态。它们不需要混合,用 alpha test(透明度测试)直接丢弃透明像素:

// 片元着色器
vec4 texColor = texture(diffuse, TexCoords);
if (texColor.a < 0.5)
    discard;                    // 丢弃片元,什么都不写
FragColor = vec4(texColor.rgb, 1.0);

discard 掉的片元不写颜色也不写深度,剩下的片元 alpha 恒为 1,深度写入照常——所以镂空物体当作不透明物体处理:参与深度测试与深度写入、与其他镂空物体不需要排序、也吃得到 剔除总结 里 front-to-back 排序的收益。

代价有两个:

  1. discard 会禁用 Early-Z(见 剔除总结),大面积镂空材质造成明显的 overdraw;
  2. 边缘是硬的二值切换,alpha 从 0.4 跳到 0.6 就是"有/无"的突变,锯齿明显。

第二个问题有一个结合 MSAA 的缓解:alpha to coverage——把片元的 alpha 映射为多重采样的覆盖掩码(coverage mask),alpha = 0.5 时 MSAA 的子采样点一半写入一半不写入,resolve 后(见 抗锯齿算法)得到平滑边缘:

glEnable(GL_SAMPLE_ALPHA_TO_COVERAGE);    // 需要开启 MSAA

5. 顺序无关透明(OIT)

排序的种种烦恼催生了 OIT(Order-Independent Transparency,顺序无关透明):不排序、一遍或多遍绘制,管线自己保证正确的合成顺序。三种代表方案。

5.1 Depth Peeling:深度剥离

深度剥离(depth peeling)逐层"剥"出半透明:每一遍渲染找出当前最前面的一层半透明表面。

  • Pass 1:正常渲染半透明物体,只保留每个像素深度最小的片元——这是第 1 层(最前面的玻璃);
  • Pass 2:再渲染一遍,只保留"深度比第 1 层更远"中最小的片元——第 2 层(第 1 层背后的第一片玻璃);
  • 依此类推 NN 遍,每遍用上一遍的深度纹理作为"最小深度屏障"(着色器里比较片元深度与上一层的深度,只接受更远的最近者),剥出 NN 层,最后按从后到前合成。

层与层的约束靠"上一遍的深度纹理 + 片元着色器 discard"实现,原理与 ShadowMap与PCF、PCSS 的深度比较一脉相承。dual depth peeling 每遍同时剥最前与最后两层,遍数减半。

优点 缺点
结果精确 几何 pass 数 = 层数,NN 遍全场景
实现相对直观 带宽与 draw call 成倍
层数可控 层数固定,超过 NN 层的透明被丢弃

5.2 Per-Pixel Linked List:逐像素链表

不再一遍遍剥,而是一遍把所有半透明片元收集起来,逐像素排序合成:

  • 几何 pass:半透明物体只画一遍。每个片元(颜色 + 深度 + next 指针)作为一个节点,用 image 原子操作(见 ComputeShader)追加到该像素的链表里,节点存放在一块预分配的大缓冲中;
  • 合成 pass:全屏四边形逐像素遍历链表,按深度排序(片元数量少,插入排序即可),从后到前 over 合成。
优点 缺点
一遍几何 每像素片元数不定,缓冲要预分配上限
结果精确、层数不限 原子操作竞争、带宽压力
- 显存开销与最坏情况绑定

5.3 Weighted Blended OIT:加权混合

前两种都太贵,加权混合 OIT(weighted blended OIT)选择不追求精确顺序:每个半透明片元按深度加权(越近权重越大)累加进两张缓冲,最后一次性合成:

Cfinal=∑iw(zi,αi) αiCi∑iw(zi,αi) αi⏟加权平均色⋅(1−∏i(1−αi))⏟总不透明度C_{final} = \underbrace{\frac{\sum_i w(z_i, \alpha_i)\, \alpha_i C_i}{\sum_i w(z_i, \alpha_i)\, \alpha_i}}_{\text{加权平均色}} \cdot \underbrace{\left(1 - \prod_i (1 - \alpha_i)\right)}_{\text{总不透明度}}

其中 w(zi,αi)w(z_i, \alpha_i) 是深度加权函数(如 w=α⋅max⁡(ϵ,3×103⋅(1−z)3)w = \alpha \cdot \max(\epsilon, 3 \times 10^3 \cdot (1-z)^3),近处权重大)。两张缓冲分别累加"加权的 αC\alpha C"与"加权因子",加法满足交换律,与顺序无关。

优点 缺点
一遍 pass、两张缓冲,快 顺序是近似,颜色间遮挡关系不准确
显存恒定、无链表无原子 权重函数需要按场景调
- 不适合需要精确折射/反射的玻璃

烟雾、体积感粒子这类"层与层差异平滑"的效果用加权混合几乎看不出问题。

5.4 实践的选择

注:现代游戏的主流仍然是排序 + 混合:半透明物体规模可控时它最便宜,视觉误差通常可接受。OIT 用在排序明显穿帮的场景——大量重叠的半透明(浓烟、头发、密集植被)。加权混合 OIT 因为实现简单、性能稳定,是三者中最常被引擎采用的。


6. 延迟渲染中的半透明

延迟渲染把几何属性烘焙进 G-Buffer、着色推迟到屏幕空间(见 前向渲染与延迟渲染)。G-Buffer 每个像素只存一层表面,而半透明恰恰是多层的,两者天然冲突。标准做法是混合管线:

  1. 不透明物体走延迟路径(G-Buffer + 光照 pass);
  2. 半透明物体在光照结果上走前向路径:排序 + 混合,逐光源着色后 over 到已经算好光照的画面上。

所以一套完整的引擎渲染一帧的顺序通常是:

1. 深度 prepass / shadow map(见 [[剔除总结]])
2. 不透明物体(延迟:G-Buffer;或前向)
3. 天空盒(深度最远,最先画会浪费填充率,放在不透明之后用已填充的深度剔除)
4. 半透明物体(远到近,关深度写)
   - 普通半透明:排序 + over 混合
   - 镂空:提前到不透明阶段用 alpha test
   - 发光体:加法混合(免排序)
   - 排序穿帮严重时:加权混合 OIT
5. 后处理(Bloom、色调映射等,见 [[帧缓冲]])

7. 总结

需求 方案 排序 深度写入
玻璃、水 over 混合 物体级远到近 关
树叶、栅栏镂空 alpha test(+ alpha to coverage) 不需要(按不透明处理) 开
火焰、光晕 加法混合 不需要(交换律) 关
大量重叠粒子、烟雾 加权混合 OIT 不需要 关
精确多层玻璃 depth peeling / 逐像素链表 管线内完成 关

半透明的一切麻烦都源于一个矛盾:深度缓冲只表达二元遮挡,而透明是按比例遮挡。各种技术都是在"排序的代价"与"合成的精度"之间寻找平衡点。