半透明物体渲染
需要预先学习:
1. 透明与混合
玻璃、水面、烟雾、粒子——真实感场景离不开半透明。不透明物体的遮挡由深度缓冲解决,谁近谁显示,与顺序无关(见 深度缓冲);半透明则不同:它的颜色要与背后已经画好的内容按比例叠加,而"按比例叠加"天然依赖顺序。这篇笔记讨论半透明渲染的所有关键问题:混合怎么做、顺序怎么排、什么时候可以不排序、以及不排序的精确方案(OIT)。
1.1 alpha 通道
颜色由 RGBA 四个分量组成,第四个分量 alpha 表示不透明程度,范围 :
| alpha | 含义 |
|---|---|
| 1 | 完全不透明,正常覆盖背后内容 |
| 0 | 完全透明,不可见 |
| 0 ~ 1 | 半透明,与背后内容按比例混合 |
1.2 over 混合方程
半透明合成使用 over 操作符:源颜色(source,即将写入的片元)以自身 alpha 为权重,覆盖目标颜色(destination,颜色缓冲中已有的内容):
一堵 的蓝色玻璃墙,最终颜色就是 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 操作符不满足交换律。红蓝两片 的玻璃,先后不同、结果不同:
顺序错了,画面就错了。由此得到半透明渲染的两条基本规则。
2.1 规则一:先画不透明,再画半透明
一帧的绘制顺序:
- 所有不透明物体:深度测试 + 深度写入照常,绘制顺序任意(近到远更优,见 剔除总结);
- 半透明物体:按规则二排序绘制。
为什么必须这个顺序:半透明的 over 需要"背景"已经就位。如果不透明物体还没画,半透明片元混合到的是垃圾颜色;更糟的是不透明物体后画会用深度测试盖掉半透明——玻璃画好了,玻璃后面的墙再画时深度更近(或深度写入冲突),玻璃就被切掉了。
2.2 规则二:半透明从远到近
半透明之间按视距从远到近(back-to-front)绘制:先画远处的,近处的叠在上面。这与画家算法(见 深度缓冲)一个道理——over 的正确合成顺序就是从后往前叠。
2.3 深度写入:关闭
半透明绘制时保留深度测试、关闭深度写入:
glDepthMask(GL_FALSE); // 只测试,不写入深度
// ... 绘制半透明物体
glDepthMask(GL_TRUE); // 恢复,供下一帧使用
关闭深度写入的原因:
- 深度测试仍必要:半透明物体要被不透明物体正确遮挡——墙后的玻璃不该透过墙被看见,这靠深度测试完成;
- 深度写入必须关:如果玻璃写了深度,比它远的其他半透明(穿过玻璃的烟、玻璃后面的玻璃)深度测试失败,直接消失。半透明本来就不该"遮挡"更远的东西,它只按 alpha 的比例部分遮挡——这个信息记不进只有一个值的深度缓冲,只能靠绘制顺序保证。
深度缓冲是"二元遮挡"的结构:每个像素只记一个最近深度,只回答"挡或不挡"。半透明是"按比例遮挡",与这个结构天然不兼容,这也是半透明一切麻烦的根源。
注:这条规则有例外。多层水面、大面积薄玻璃等"近处的不透明感较强"的场景,有时故意保留深度写入,接受远处的半透明消失,换取性能与深度排序稳定性——一种实用的权宜。
3. 排序怎么做与排不动的时候
规则二要求排序,实践中按粒度分几档:
| 粒度 | 做法 | 适用 |
|---|---|---|
| 逐物体 | CPU 按视距排序整个物体 | 玻璃、水这类数量少的独立物体 |
| 逐粒子 / 逐实例 | 粒子系统内部排序 | 烟雾、火焰 |
| 逐三角形 | 网格内三角形排序 | 几乎不做,太贵 |
逐物体排序是 的 CPU 排序,半透明物体通常不多,开销可以接受。排不动的情况:
- 大物体重叠:两扇大玻璃交错,物体 A 整体在 B 前、局部在 B 后,任何整体顺序都是错的;
- 凹形自遮挡:一只玻璃杯,自身前壁与后壁交错,逐物体排序(甚至逐三角形排序)都无法完全正确;
- 动态交错:粒子与网格穿插。
工程上对这类误差普遍容忍:视觉上不显眼的错误顺序,比付出精确排序的代价划算。真正追求精确时,用后面的 OIT。
3.1 与顺序无关的加法混合
加法混合(additive blending)天生不需要排序:
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 排序的收益。
代价有两个:
discard会禁用 Early-Z(见 剔除总结),大面积镂空材质造成明显的 overdraw;- 边缘是硬的二值切换,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 层背后的第一片玻璃);
- 依此类推 遍,每遍用上一遍的深度纹理作为"最小深度屏障"(着色器里比较片元深度与上一层的深度,只接受更远的最近者),剥出 层,最后按从后到前合成。
层与层的约束靠"上一遍的深度纹理 + 片元着色器 discard"实现,原理与 ShadowMap与PCF、PCSS 的深度比较一脉相承。dual depth peeling 每遍同时剥最前与最后两层,遍数减半。
| 优点 | 缺点 |
|---|---|
| 结果精确 | 几何 pass 数 = 层数, 遍全场景 |
| 实现相对直观 | 带宽与 draw call 成倍 |
| 层数可控 | 层数固定,超过 层的透明被丢弃 |
5.2 Per-Pixel Linked List:逐像素链表
不再一遍遍剥,而是一遍把所有半透明片元收集起来,逐像素排序合成:
- 几何 pass:半透明物体只画一遍。每个片元(颜色 + 深度 + next 指针)作为一个节点,用 image 原子操作(见 ComputeShader)追加到该像素的链表里,节点存放在一块预分配的大缓冲中;
- 合成 pass:全屏四边形逐像素遍历链表,按深度排序(片元数量少,插入排序即可),从后到前 over 合成。
| 优点 | 缺点 |
|---|---|
| 一遍几何 | 每像素片元数不定,缓冲要预分配上限 |
| 结果精确、层数不限 | 原子操作竞争、带宽压力 |
| - | 显存开销与最坏情况绑定 |
5.3 Weighted Blended OIT:加权混合
前两种都太贵,加权混合 OIT(weighted blended OIT)选择不追求精确顺序:每个半透明片元按深度加权(越近权重越大)累加进两张缓冲,最后一次性合成:
其中 是深度加权函数(如 ,近处权重大)。两张缓冲分别累加"加权的 "与"加权因子",加法满足交换律,与顺序无关。
| 优点 | 缺点 |
|---|---|
| 一遍 pass、两张缓冲,快 | 顺序是近似,颜色间遮挡关系不准确 |
| 显存恒定、无链表无原子 | 权重函数需要按场景调 |
| - | 不适合需要精确折射/反射的玻璃 |
烟雾、体积感粒子这类"层与层差异平滑"的效果用加权混合几乎看不出问题。
5.4 实践的选择
注:现代游戏的主流仍然是排序 + 混合:半透明物体规模可控时它最便宜,视觉误差通常可接受。OIT 用在排序明显穿帮的场景——大量重叠的半透明(浓烟、头发、密集植被)。加权混合 OIT 因为实现简单、性能稳定,是三者中最常被引擎采用的。
6. 延迟渲染中的半透明
延迟渲染把几何属性烘焙进 G-Buffer、着色推迟到屏幕空间(见 前向渲染与延迟渲染)。G-Buffer 每个像素只存一层表面,而半透明恰恰是多层的,两者天然冲突。标准做法是混合管线:
- 不透明物体走延迟路径(G-Buffer + 光照 pass);
- 半透明物体在光照结果上走前向路径:排序 + 混合,逐光源着色后 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 / 逐像素链表 | 管线内完成 | 关 |
半透明的一切麻烦都源于一个矛盾:深度缓冲只表达二元遮挡,而透明是按比例遮挡。各种技术都是在"排序的代价"与"合成的精度"之间寻找平衡点。



