软粒子:粒子为什么不能有硬边
全课骨架:特效 = 一大堆各自独立的小东西,每一个只做三件事 —— 按自己的速度挪一步、 按自己的寿命决定还剩多少不透明、最后按「从远到近」的顺序盖到画面上。 前两课都在空的空间里做这件事:粒子之间只有"谁盖谁"的问题,没有任何东西会挡住它们。 可真场景里满地都是东西 —— 于是粒子与物体的交界处立刻出现一条硬边:圆盘被切掉,切口在一个像素里从满值掉到 0。 这一课只做一件事:把"谁盖谁"从二值判定换成一个连续量 —— 用"这一像素处物体离相机多远"与"粒子离相机多远"之差,决定这一像素还剩多少不透明。 保真度:画面走 CPU 软光栅(示意 · 等价重现); 读数真机可证 —— 场景深度、线性化、交界行号、过渡带宽度、恒等性、深度测试的冗余性 都是本页真算出来的数,而且每条都有闭式可以拿笔核。
本页的原理与数值表照常可读(画面只是把同一个数画出来)。
(脚本启动中…)
定点观察:先点「9.3 按深度差淡出」,再在「软淡出」开/关之间切 → 盯住烟团底部那一条边,同时看下方读数里的「交界行」与「最大相邻 α 跳变」 → 关着的时候那是一条刀切的直线(相邻像素一步 0.5+),开着的时候它变成一条宽约 17 像素的斜坡; 而离地面比 D 还远的像素,两档逐位相同(判据⑥ 报 0)。
本帧的读数
(脚本启动中…)
9.1 粒子撞上地面:那条硬边是从哪来的
本段只增加一样东西:实体几何 + 逐像素的「谁盖谁」(其余沿用前两课:同一份粒子引擎、
同一批戳记、同一个寿命曲线、同一个"从远到近"的盖法)。
前两课的粒子飘在一片空的空间里 —— 它们之间确实要排序(第 1 课讲过),可没有任何东西挡住它们。
真实场景满地都是东西。把场景放进来之后,画面上立刻多出一条以前没见过的边:
粒子圆盘与地面的交界处,alpha 在一个像素里从满值掉到 0。
而且这条边的形状特别好认:它是一条水平直线。原因是场景深度这一侧只跟屏幕行有关
(水平地面的深度只随行变),所以"粒子被挡住"的分界就落在某一行上,行号有闭式:
py* = 300 + (f/cosα)·(h/z_p − sinα)。
| 寿命 u | z_p(眼距) | 中心行 | 半径 r | 交界行 py* | 实测交界行 | 被切掉行数 | 两路之差 (px) |
|---|
9.2 为什么要单独存一张"深度图"
本段只增加一样东西:把深度当数据读出来(比较 → 采样;其余控件沿用上一段)。
上一段其实已经在用场景深度了 —— 但它只用了一次比较(z_p > z_s ⇒ 丢掉),
那个数用完就扔。要治硬边,光知道"挡住了"不够,得知道"差多少",也就是要把这个数读出来。
真机上这一步不是白给的:Unity 把软粒子写成质量设置里的一个开关,并明确前置条件 ——
"only works on platforms that support depth textures"(DX9 时代根本读不到主深度缓冲)。
而读出来之后立刻会撞上第二件事:那里面的数不是距离。它正比于 1/z,
所以它在屏幕上对平面是线性的(这正是硬件能线性插值深度的原因),但它离"米"很远。
不线性化会得到什么?表② 最后一列给了一个数:同样的深度差 Δz_ndc = 1e−3,
在画面最下面一行是 3.37 mm、在天上是 39.8 m —— 差 11813 倍。
而 Unreal 的 DepthFade 节点把它的输入写成
"World space distance over which the fade should take place":要的是世界距离。
| 屏幕行 | 面 | 眼距 z(射线) | 硬件深度 z_ndc | 线性化回来 | 差 | Δz_ndc=1e−3 ⇒ 世界距离 |
|---|
9.3 按深度差淡出:硬边变成斜坡
本段只增加一样东西:淡出因子(a *= clamp((z_s − z_p)/D, 0, 1),
连同它唯一的常数 D;其余控件与上一段完全相同)。
上一段手里已经有两个数了:这一像素处物体离相机多远、粒子离相机多远。可画面一点都没变 ——
因为还没人拿它们的差去做事。这一段只加一行乘数,于是那条刀切的直线变成一条斜坡:
- 在两深度相等的那一行(交界行)上
gap = 0⇒fade = 0,粒子完全让位给地面; - 往上走
gap变大、α 渐渐回到原值,直到gap = D才 1:1 复原; - 过渡带的宽度有闭式:
W = D·f·h / (cosα·z_p·(z_p+D))—— 正比于 D, 而且 ∝ 1/z²:同一个 D,越远的交界在屏幕上越窄。
| 表③-a 屏幕行 | 场景深度 z_s | gap | fade | α_hard | α_soft |
|---|
| 淡出距离 D | 闭式 W (px) | 实测 W (px) | 差 (px) | 相对上一档 |
|---|
9.4 深度测试还要不要:它是被"替换"的
本段只增加一样东西:深度测试的开关(其余控件与上一段完全相同)。
回头看一眼上一段那个乘数:z_p > z_s 时 (z_s − z_p)/D < 0,
clamp 之后恰好是 0;而"用一个 alpha = 0 的颜色去盖"什么都不写。
也就是说,9.1 里那行二值判定已经被这个乘数覆盖了。于是有一个干净的断言:
把深度测试整个关掉,画面应当逐位不变(判据⑦ 实测:不同的像素数 = 0、逐通道最大差 = 0)。
这不是巧合,GPU Gems 3 那一节的 Listing 标题就写着
Soft Particles Are Better Than a Binary Depth Test,正文里那句更直接:
"zFade value replaces the comparison and discard" —— 替换,不是叠加。
但代价要说清:深度测试是硬件白给的(early-z,它不读那张图);软淡出每像素要多读一次深度、
多做一次 clamp 与乘法。Unity 的文档把这件事写成代价与前置条件
("more expensive to compute… only works on platforms that support depth textures")。
读数的"性能"一行把两档的绘制耗时并排报出来。
| 合成方式 | 交界处的 α 跳变 | 被深度测试丢掉的写入 | 与「软+深度测试」不同像素数 | 与硬档不同像素数 |
|---|
这一课的位置
四段只做了一件事:把"粒子与物体的交界"从二值判定换成连续判定。
粒子引擎、戳记、无散流场、寿命曲线、排序、混合全部沿用前两课,一行没改。
本页那一格"如实说明":画面是 CPU 软光栅逐像素读深度图、逐层混合写进帧缓冲的(示意 · 等价重现);
真机上这一步在像素着色器里做(tex2D(depthSampler, uv) 取场景深度),
本页不声称复现那条内部路径。另外本页的戳记改用最近邻采样(64² 的戳记投到 60~90 px 的粒子上,
比例接近 1 : 1.4,双线性的四个权重省下来是白赚的)—— 这一处与前两课不同,如实标出。
本课题还没做的两课:后处理链(bloom:光为什么要"溢"出来)、屏幕空间(SSAO 与 SSR)。
本页用到的做法,出处在哪
- 软粒子的原始做法 —— GPU Gems 3 第 23 章(Iain Cantlay, NVIDIA)High-Speed, Off-Screen Particles
的 §23.4「Depth Testing and Soft Particles」:Listing 23-1 是二值判定(
if (myDepth > sceneDepth) discard;), Listing 23-2 就是本页 9.3/9.4 那两行(zFade = saturate(scale * (myDepth - sceneDepth))→a * zFade);原文点题的一句是 "zFade value replaces the comparison and discard", 那一节的 Listing 标题是 Soft Particles Are Better Than a Binary Depth Test: developer.nvidia.com/gpugems/gpugems3。 ★ 一句如实说明:原文的myDepth/sceneDepth谁大谁远没有明说 (Listing 23-1 的丢法、Listing 23-2 的符号,与"深度越大越远"的直觉放在一起是对不上的)。 本页把两处都写成显式的:越大越远 ⇒ 判定z_p > z_s丢掉、淡出clamp((z_s − z_p)/D)。 - 引擎里的对应节点 —— Unreal 的 DepthFade:文档写它"used to hide unsightly seams that take place
when translucent objects intersect with opaque ones",输入
FadeDistance的说明是 "World space distance over which the fade should take place" —— 这句就是本页 9.2 的立项依据 (世界距离不在深度缓冲里,得先线性化): Utility Material Expressions。 - 代价与前置条件 —— Unity 的 Quality Settings 里 Soft Particles 那一条:文档说粒子 "fade out near intersections with other scene geometry",并注明它 "more expensive to compute (more complex pixel shaders), and only works on platforms that support depth textures" —— 9.2 段"要一张能采样的深度图"与 9.4 段"代价在哪"都出自这里: Unity Manual · Quality。
- 深度缓冲里存的是 1/z —— NVIDIA 的 Visualizing Depth Precision 把这件事写成
"GPU hardware depth buffers store values proportional to the reciprocal of world-space depth (1/z)
rather than linear distance":developer.nvidia.com/blog/visualizing-depth-precision;
线性化的公式(
(2·near·far)/(far + near − z·(far − near)))见 LearnOpenGL 的 Depth testing 一节: learnopengl.com/advanced-opengl/depth-testing。 - 本页故意没做的:半分辨率离屏粒子(GPU Gems 3 第 23 章的主线)—— 那是性能话题, 不是软边话题,留给后处理链那一课;体积/网格流体不在本课题范围内。