← 图形原理 · 交互课 课题七 · 特效 · 第 3 课

软粒子:粒子为什么不能有硬边

全课骨架:特效 = 一大堆各自独立的小东西,每一个只做三件事 —— 按自己的速度挪一步、 按自己的寿命决定还剩多少不透明、最后按「从远到近」的顺序盖到画面上。 前两课都在空的空间里做这件事:粒子之间只有"谁盖谁"的问题,没有任何东西会挡住它们。 可真场景里满地都是东西 —— 于是粒子与物体的交界处立刻出现一条硬边:圆盘被切掉,切口在一个像素里从满值掉到 0。 这一课只做一件事:把"谁盖谁"从二值判定换成一个连续量 —— 用"这一像素处物体离相机多远"与"粒子离相机多远"之差,决定这一像素还剩多少不透明。 保真度:画面走 CPU 软光栅(示意 · 等价重现); 读数真机可证 —— 场景深度、线性化、交界行号、过渡带宽度、恒等性、深度测试的冗余性 都是本页真算出来的数,而且每条都有闭式可以拿笔核。

分段:
合成: | |
0.6 | 场景:地面 y=0 · 墙 z=9(背景)· 天 = 远平面 200

(脚本启动中…)

定点观察:先点「9.3 按深度差淡出」,再在「软淡出」开/关之间切 → 盯住烟团底部那一条边,同时看下方读数里的「交界行」与「最大相邻 α 跳变」 → 关着的时候那是一条刀切的直线(相邻像素一步 0.5+),开着的时候它变成一条宽约 17 像素的斜坡; 而离地面比 D 还远的像素,两档逐位相同(判据⑥ 报 0)。

本帧的读数

(脚本启动中…)

9.1 粒子撞上地面:那条硬边是从哪来的

本段只增加一样东西:实体几何 + 逐像素的「谁盖谁」(其余沿用前两课:同一份粒子引擎、 同一批戳记、同一个寿命曲线、同一个"从远到近"的盖法)。
前两课的粒子飘在一片空的空间里 —— 它们之间确实要排序(第 1 课讲过),可没有任何东西挡住它们。 真实场景满地都是东西。把场景放进来之后,画面上立刻多出一条以前没见过的边: 粒子圆盘与地面的交界处,alpha 在一个像素里从满值掉到 0。
而且这条边的形状特别好认:它是一条水平直线。原因是场景深度这一侧只跟屏幕行有关 (水平地面的深度只随行变),所以"粒子被挡住"的分界就落在某一行上,行号有闭式: py* = 300 + (f/cosα)·(h/z_p − sinα)。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 场景:地面 y=0 墙 z=9(背景) 相机眼高 1.5 俯角 15° 焦距 520 粒子 (x, y, z) 与寿命 u ★ 本段只加实体几何 /* 场景深度:射线与地面/墙求交,取最近的那个 */ function sceneZ(px, py) { const d = ray(px, py); // 像素 → 世界射线 if (d.dy < 0) t = -H_EYE / d.dy; // 地面:y = 0 if (d.dz > 0) t = min(t, ZW / d.dz); // 墙:z = 9 return t; // ★ 该像素的眼距 } if (zp > zs) continue; // 二值:挡住就整块丢掉 // ★ 硬边就出在这一行:一个像素之内 满值 → 0 交界行闭式: py* = 300 + (f/cosα)(h/z_p − sinα) z_p = 4.0 → 362.1 z_p = 5.0 → 321.7 (见表①,与实测逐档核) 交界处跳变:硬 0.635 同一处软档 0.013
定点观察:先点「9.1 地面把烟切开」→ 看烟团的底部 → 它被地面切成一条水平直线(不是一个圆滑的边);而且这条线的行号只由粒子的深度决定 —— 因为水平地面的深度只随屏幕行变。表① 把五颗粒子的「交界行闭式」与「逐行扫出来的行」并排放着,逐档可核。 ★ 本页只有一个物体真的会切到烟(地面);墙与天在烟后面,它们的作用是让深度图有三层。
表① 五颗粒子(从最年轻到最老,取自本页稳态的烟团):交界行闭式 py* = 300 + (f/cosα)(h/z_p − sinα) 与页内逐行扫出来的交界行必须对上; 末列是"这颗的圆盘被切掉多少行"(= 中心行 + 半径 − 交界行,负数表示没被切到)。 ★ 表里前两行是没被切到的对照(老烟已经升到交界线以上了,扫描给不出交界行,记 "—"); 后三行的"两路之差" ≤ 1 px,那是像素量化:闭式给的是分数行号,扫描给的是整数行。 ★ 表中 z_p 是粒子中心的眼距,h = 1.5、α = 15°、f = 520,全部可以从画面参数拿笔算出来
寿命 uz_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":要的是世界距离。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 深度缓冲:一像素 一个数(本页烘一次) 相机近 / 远 = 1/200 地面 1.84 ~ 200 墙 ≈ 8.5~10 天 = 远平面 200 ★ 本段只加"读它" // 硬件深度(近→0、远→1),它与世界距离不成正比 const ndc = (t) => c1 - c2 / t; // c1 = c2 = 200/199 // 要当"世界距离"用,必须反解回来(线性化) const lin = (z) => (FAR*NEAR) / (FAR - z*(FAR-NEAR)); // ★ 1/z 才是对屏幕行仿射的那个量: // 1/z = (sinα + (py+0.5-CY)·cosα/f) / h // ⇒ 每一行的差分 = cosα/(f·h) = 1.2384e-3(常数) ★ 两条独立路线(闭式 / 射线求交)实测差 2.4e−16 z_ndc(1.840) = 0.4589 z_ndc(200) = 1.0000 同样 Δz_ndc = 1e−3 底行 ⇒ 3.37 mm 天上 ⇒ 39.8 m 差 11813 倍 ⇒ 必须先线性化
定点观察:点「9.2 一张深度图」(或直接按「深度图」开关)→ 看画面变成一张灰图 (上远下近、天/墙/地面各一层)→ 深度缓冲里存的是 1/z 的仿射量:它在屏幕上对平面是线性的 (这正是硬件能线性插值深度的原因),但它不是距离。 ★ 本页的深度可视化显示的是线性化之后的眼距(好看得懂);深度缓冲里原来那个数在表② 第 3 列, 以及读数行里的「同 Δz_ndc 对应的世界距离」。
表② 八个采样点(px = 480):深度缓冲里的数不是距离。第 3 列是本页烘出来的硬件深度, 第 4 列是把它线性化回来(lin(ndc(t))),第 5 列是逐像素射线求交得到的眼距 —— 两者必须相等(末列差 ≤ 1e−14);最后一列是"那个深度刻度在这里等于多少世界距离"。 ★ 表② 第 6 行的墙:它在深度图里只比地面远一点点,可世界距离差 3 米 —— 这正是 1/z 的压缩
屏幕行面眼距 z(射线)硬件深度 z_ndc线性化回来差Δz_ndc=1e−3 ⇒ 世界距离

9.3 按深度差淡出:硬边变成斜坡

本段只增加一样东西:淡出因子(a *= clamp((z_s − z_p)/D, 0, 1), 连同它唯一的常数 D;其余控件与上一段完全相同)。
上一段手里已经有两个数了:这一像素处物体离相机多远、粒子离相机多远。可画面一点都没变 —— 因为还没人拿它们的差去做事。这一段只加一行乘数,于是那条刀切的直线变成一条斜坡:

最要紧的一条是"它只动该动的地方":离地面比 D 还远的像素应当逐位不变(判据⑥ 报 0)。 软边不是给整团烟加一层模糊,它只动交界那一带。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 每像素读一次 场景深度 z_s 淡出距离 D = 0.6 (滑块 0.1 ~ 2.0) 交界行 346.5 过渡带 323 ~ 347 ★ 本段只加淡出因子 const zs = ZB[py * W + px]; // 这一像素的场景深度 const fade = clamp((zs - zp) / G.D, 0, 1); // ★ 本段唯一新增 over(px, py, col, a * fade); // 二值判定 → 一个乘数 // 交界行上 gap = 0 ⇒ fade = 0:粒子完全让位给地面 // gap ≥ D ⇒ fade = 1:这一像素逐位回到硬档的样子 // 中间的过渡带宽度(像素): // W = D·f·h / (cosα · z_p · (z_p + D)) ★ 实测:D 三档 0.3/0.6/1.2 ⇒ 宽 12.5 / 23.5 / 40.5 px z_p = 4.33、D = 0.6: W = D·f·h / (cosα·z_p(z_p+D)) = 22.7 px(实测 23.5) D 翻倍 ⇒ 宽翻倍 ∝ 1/z²:越远越窄 而远处之外逐位不变
定点观察:在「软淡出」开着的时候拖「淡出距离 D」(0.1 → 2.0)→ 看烟团底部那条边 → 它是一条宽窄随 D 走的斜坡:D 从 0.6 拉到 1.2,宽度从 23.5 px 变成 40.5 px; 而画面上离地面比 D 还远的那些像素,两档逐位相同(读数里报「恒等像素」的个数,判据⑥ 报最大差 0)。 ★ 表③ 把这条竖线上的 9 行摊开:gap、fade、α_hard、α_soft —— 后面三列都能拿笔算。
表③ 一条竖线上的淡出(取烟团里被切得最深的那颗粒子的中心列):后三列都能拿笔算 —— gap = z_s − z_p、fade = clamp(gap/D, 0, 1)、α_soft = α_hard × fade。 α_hard 那一列是本页噪声戳记在该像素上的实测值(给定,不可手算)。 下半段是过渡带宽度:闭式 W = D·f·h/(cosα·z_p(z_p+D)) 与逐行扫出来的实测宽度对照
表③-a 屏幕行场景深度 z_sgapfadeα_hardα_soft
表③-b 过渡带宽度:同一颗粒子(z_p 固定),三档 D 的闭式与实测(实测 = 从交界行往上扫, 直到 gap ≥ D 的那一行;差值来自像素中心的离散化)
淡出距离 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")。 读数的"性能"一行把两档的绘制耗时并排报出来。

输入 这个阶段在执行的规则(照抄件二真源码) 输出 深度测试:开/关 (硬件 early-z) 软淡出:开 两者在 z_p > z_s 处重合 ★ 本段只加这个开关 // 软档下,那个二值判定已经被乘数覆盖: // z_p > z_s ⇒ (z_s - z_p)/D < 0 ⇒ clamp ⇒ 0 ⇒ 不写 if (G.ztest && zp > zs) continue; // ★ 这一行删掉也不该有差别 const fade = clamp((zs - zp) / G.D, 0, 1); over(px, py, col, a * fade); ★ 实测:开 / 关两档 ⇒ 不同像素数 = 0、最大差 = 0 // 代价在别处:软档每像素多读一次深度、多一次 clamp // 硬件 early-z 不读那张图 —— 拿"读图"换掉了"二值判定" 关掉深度测试: 不同像素数 = 0 逐通道最大差 = 0 ⇒ 逐位相同 原文:zFade replaces the comparison and discard (替换,不是叠加)
定点观察:在「9.4 深度测试还要不要」这一段里按「深度测试」把它关掉 → 盯住整个画面(尤其是烟团底部那条斜坡)→ 一个像素都没变:读数报「不同像素数 = 0」, 表④ 里那一行也必须是 0。软淡出不是"在深度测试之上再加一层淡出",它把那个二值判定换成了一个连续量。 ★ 但两者不是"等价":硬件 early-z 不读深度图,软淡出每像素要多读一次 —— 性能读数那行把代价写在明处。
表④ 三种合成方式的对照:末两列是"与软档(开深度测试)不同的像素数" —— 第三行必须是 0(逐位相同),这就是 9.4 的全部主张;第二列是交界那一行上的 α 跳变 (硬档 = 整块丢掉,跳变就是上一行的 α;软档 = 只剩它的 1/W),它是 9.1/9.3 的看点。 ★ 表里那些"不同的像素数"是把同一堆粒子按三种方式各画一遍、再逐像素比出来的(判据⑥⑦ 用它)
合成方式交界处的 α 跳变被深度测试丢掉的写入与「软+深度测试」不同像素数与硬档不同像素数

这一课的位置

四段只做了一件事:把"粒子与物体的交界"从二值判定换成连续判定。 粒子引擎、戳记、无散流场、寿命曲线、排序、混合全部沿用前两课,一行没改。
本页那一格"如实说明":画面是 CPU 软光栅逐像素读深度图、逐层混合写进帧缓冲的(示意 · 等价重现); 真机上这一步在像素着色器里做(tex2D(depthSampler, uv) 取场景深度), 本页不声称复现那条内部路径。另外本页的戳记改用最近邻采样(64² 的戳记投到 60~90 px 的粒子上, 比例接近 1 : 1.4,双线性的四个权重省下来是白赚的)—— 这一处与前两课不同,如实标出。
本课题还没做的两课:后处理链(bloom:光为什么要"溢"出来)、屏幕空间(SSAO 与 SSR)。

本页用到的做法,出处在哪

运行时自检 等待脚本…