相机几乎没动,透明粒子的交叠颜色为什么还在闪

相机只移动了很小一段距离,烟雾粒子的位置也没有发生肉眼可见的变化,交叠区域却在冷灰与暖橙之间来回跳。这类闪烁容易被归因于浮点误差或透明材质本身,真正值得检查的是绘制顺序:两个深度非常接近的粒子落进同一个排序桶后,如果比较器只返回“相等”,后续顺序就可能取决于并行收集、容器遍历或排序实现。
Alpha 混合不满足交换律。先画 A 再画 B 与先画 B 再画 A 通常得到不同颜色,因此“深度相等”不能等同于“顺序无关”。排序合同必须回答两件事:不同深度谁先画;深度被量化为相同值时,谁拥有确定的次序。
深度桶解决抖动,却留下了同值次序
逐粒子直接比较浮点深度,会让微小相机扰动频繁交换近邻粒子。把相机空间深度按固定步长量化,可以建立有限精度的排序域。本文探针使用 0.01 的步长:深度 12.004 与 12.001 都映射为桶 1200,而 18.200 与 7.500 分别映射为 1820、750。
这个量化只定义了主键。探针约定相机前方深度为正值,数值越大表示离相机越远;若引擎使用相反的相机空间轴向,应先统一为这一标量,不能直接照搬降序方向。若桶内仍沿用输入数组顺序,那么工作线程完成先后改变数组排列时,渲染结果仍会变化。探针把发射序号放进粒子身份,由发射器单调分配;排序键由“深度桶降序、发射序号升序”组成。远粒子先绘制,同桶粒子回到稳定的生命周期顺序。

核心实现位于 $LAB_ROOT/Program.cs 的 TransparentParticleSorter.TrySort。它先确认比较域完整,再一次性生成可供绘制的有序快照:
readonly record struct Particle(int Id, float ViewDepth, uint EmissionOrder);
sealed class TransparentParticleSorter
{
private readonly float depthStep;
public TransparentParticleSorter(float depthStep)
{
if (!float.IsFinite(depthStep) || depthStep <= 0f)
throw new ArgumentOutOfRangeException(nameof(depthStep));
this.depthStep = depthStep;
}
public IReadOnlyList<Particle> Active { get; private set; }
= Array.Empty<Particle>();
public SortResult TrySort(IReadOnlyList<Particle> particles)
{
if (particles.Count == 0)
return SortResult.EmptyBatch;
if (particles.Any(particle => !float.IsFinite(particle.ViewDepth)))
return SortResult.NonFiniteDepth;
if (particles.Select(particle => particle.EmissionOrder)
.Distinct().Count() != particles.Count)
return SortResult.DuplicateEmissionOrder;
Active = particles
.OrderByDescending(particle => Quantize(particle.ViewDepth))
.ThenBy(particle => particle.EmissionOrder)
.ToArray();
return SortResult.Applied;
}
private int Quantize(float depth) =>
checked((int)MathF.Round(depth / depthStep));
}
发射序号不是数组索引。数组索引属于本帧收集过程,发射序号属于粒子的生命周期;只有后者能跨线程分区、可见性筛选和缓冲压缩保持同义。序号还必须在当前排序域内唯一,否则次键仍然不能构成严格全序,探针会返回 DuplicateEmissionOrder。
NaN 不能被悄悄塞进比较器
非法深度不能靠指定末尾位置解决。NaN 与普通浮点数的比较不具备排序算法要求的关系;某些比较器会把它视作相等,另一些路径则可能得到不一致结果。更危险的是,单独丢弃坏粒子会掩盖上游坐标或相机变换已经失效。
因此 TrySort 在排序前扫描整个候选批次。发现 NaN 或无穷值时返回 NonFiniteDepth,保留上一份可绘制顺序。这个失败策略适合排序结果以帧快照形式交付渲染线程的系统:宁可重复一帧旧顺序,也不让不满足全序关系的数据进入排序器。若产品要求坏粒子立即消失,可以在上游构建候选批次时隔离它,但应把隔离数量变成可观测错误,不能在比较器内部静默吞掉。
固定输入把“稳定”变成可复核结果
探针以四个粒子覆盖不同桶和同桶次序,并把输入数组故意排成 41, 17, 29, 53。Release 构建与八项回归测试执行如下:
$ dotnet build $LAB_ROOT/ParticleSortLab.csproj -c Release --no-restore
Build succeeded.
0 Warning(s)
0 Error(s)
exit_code=0 elapsed=6.09s
$ dotnet run --project $LAB_ROOT/ParticleSortLab.csproj -c Release --no-build
stable_sort result=Applied order=17>29>41>53 buckets=1820>1200>1200>750
invalid_depth result=NonFiniteDepth active_order=17>29>41>53
tests pass=8 fail=0 skip=0 duration_ms=70.42
exit_code=0
ID 29 与 41 的深度桶都是 1200,输出却稳定为 29 在前,因为它们的发射序号分别是 102 与 104。另一项测试交换同桶粒子的输入排列,输出仍为 1>2>3。注入 NaN 后,失败批次没有改写此前的 17>29>41>53,说明异常输入与活动绘制顺序之间存在明确边界。
这里验证的是 CPU 侧排序合同与状态变化,不是 GPU 排序吞吐量,所以不能据此声称它比 radix sort 更快。粒子规模扩大到需要 GPU 并行排序时,复合键仍应保留:可以把量化深度放在高位、稳定序号放在低位,再选择适合键宽与粒子数量的排序实现。双眼渲染还需要为每只眼睛计算独立深度;跨发射器合批则必须把发射器身份纳入次键或建立全局序号。
开篇的闪烁并不要求放弃深度量化。量化桶负责抑制微小深度扰动,发射序号负责消除桶内歧义,两者共同把透明混合顺序变成可重复的渲染输入。这个方案适用于传统从远到近的 Alpha 混合;采用加法混合或顺序无关透明时,顺序敏感性与排序成本需要重新评估。