Hi-Z 遮挡剔除:深度金字塔必须保留孔洞
一堵墙占满了屏幕上的一个小区域,中间留着一个孔。墙后的物体本应从孔里露出来,开启层级深度遮挡后却整个消失。检查对象身份、包围盒和回读时序都可能找不到问题,因为错误已经发生在更早的归约:深度金字塔把“这一块有近处遮挡物”误写成了“这一块全部被近处遮挡物覆盖”。

把画面缩成一个 4×4 深度块便能复现。采用近端为零、远端为一的普通深度,十五个像素是墙面深度 0.2,坐标 (1,1) 没有遮挡物,保留清屏值 1.0。候选包围区域覆盖整个块,其最靠近相机的深度为 0.6。本文用独立 C# 数值实现检查这份合同;它是渲染算法的参考核,不冒充引擎截图或 GPU 测量。
最小深度只能证明局部存在遮挡
若每个 2×2 块取最小值,孔洞所在块会变成 0.2,继续缩到一个像素仍是 0.2。此时比较 0.6 > 0.2,就会把整个候选剔除。但这个数只能说明覆盖区域内存在一个更近的深度,不能证明每个像素都被遮住。
完全遮挡需要更强的条件:候选的最近深度,也必须比覆盖范围中最远的遮挡深度更远。普通深度下记候选最近值为 zNear、区域最大值为 zFar,使用 zNear > zFar + epsilon。本例 zFar=1.0,比较不成立,因此保留候选。保留只表示尚未证明完全遮挡,不保证物体最终一定产生可见片元。
epsilon=0.001 是本次测试的深度缓冲单位容差,不是世界空间的一毫米。它让相等或十分接近的深度倾向保留;实际工程需根据深度格式、投影和量化误差制定容差,不能把本例常量解释成适用于所有视距的误差界。
反向深度把近远大小关系交换。测试用 1-d 构造同一离散场景的对应数据:墙面为 0.8,孔洞为 0.0,候选最近值为 0.4。此时最远深度对应最小值,完全遮挡条件变成 zNear < zFar - epsilon。选择 min 还是 max 必须跟随深度约定;颜色 Mip 的平均过滤与这两种保守归约都不是一回事。
粗层可以多保留对象,不能把孔洞抹掉

查询还需要覆盖正确的区域。这里用整数半开矩形 [X0,X1)×[Y0,Y1) 表达基础层像素范围,在第 level 层,每个单元覆盖边长 2^level 的基础层方块。起点向下取整,最后一个像素用 X1-1、Y1-1 定位,枚举全部相交单元。只采包围矩形中心,或者漏掉边缘跨过的单元,即使归约方向正确也会失去证明。
这种向外覆盖可能读到矩形外的孔洞。查询左上角一个像素时,基础层只有墙面 0.2,允许剔除;升到下一层就连带读到 (1,1) 的孔洞,必须保留。它降低剔除率,却符合保守性。普通深度下,扩大集合不会减小最大值;反向深度下,扩大集合不会增大最小值,两种情况都只会让剔除条件更难成立。
参考核保存在 DepthPyramid.cs。构造函数复制输入并逐层调用 Reduce;Occluded 通过 Bound 查询覆盖深度。下面是完整实现,数组使用 [y,x],输入限于二次幂正方形:
namespace DepthLab;
public readonly record struct Rect(int X0, int Y0, int X1, int Y1);
public sealed class DepthPyramid
{
private readonly List<float[,]> levels = [];
private readonly bool reversed;
public int Size { get; }
public int LevelCount => levels.Count;
public DepthPyramid(float[,] depth, bool reversed)
{
Size = depth.GetLength(0);
if (Size == 0 || Size != depth.GetLength(1) || (Size & (Size - 1)) != 0)
throw new ArgumentException("Depth must be a nonempty power-of-two square");
foreach (float d in depth)
if (!float.IsFinite(d) || d < 0 || d > 1)
throw new ArgumentException("Depth must be finite and within [0,1]");
this.reversed = reversed;
levels.Add((float[,])depth.Clone());
for (int size = Size / 2; size > 0; size /= 2)
levels.Add(Reduce(levels[^1], size));
}
private float Farther(float a, float b) => reversed ? Math.Min(a, b) : Math.Max(a, b);
private float[,] Reduce(float[,] source, int size)
{
var result = new float[size, size];
for (int y = 0; y < size; y++)
for (int x = 0; x < size; x++)
{
float d = reversed ? 1 : 0;
for (int dy = 0; dy < 2; dy++)
for (int dx = 0; dx < 2; dx++)
d = Farther(d, source[y * 2 + dy, x * 2 + dx]);
result[y, x] = d;
}
return result;
}
public float Bound(Rect rect, int level)
{
if (level < 0 || level >= levels.Count || rect.X0 < 0 || rect.Y0 < 0 ||
rect.X1 > Size || rect.Y1 > Size || rect.X0 >= rect.X1 || rect.Y0 >= rect.Y1)
throw new ArgumentException("Invalid rectangle or level");
int span = 1 << level;
float d = reversed ? 1 : 0;
// 半开矩形向外覆盖到所有相交的粗层单元。
for (int y = rect.Y0 / span; y <= (rect.Y1 - 1) / span; y++)
for (int x = rect.X0 / span; x <= (rect.X1 - 1) / span; x++)
d = Farther(d, levels[level][y, x]);
return d;
}
public bool Occluded(Rect rect, int level, float nearest, float epsilon)
{
if (!float.IsFinite(nearest) || nearest < 0 || nearest > 1 ||
!float.IsFinite(epsilon) || epsilon < 0)
throw new ArgumentException("Invalid candidate depth or tolerance");
float farthest = Bound(rect, level);
return reversed ? nearest < farthest - epsilon : nearest > farthest + epsilon;
}
}
保存输入副本使调用方后续修改数组不会改变已建立的金字塔。非有限深度、越界矩形和负容差直接拒绝,不返回一个貌似可信的遮挡结论。运行时接入层若无法形成有效查询,应保留对象并记录失败原因;不能吞掉异常后默认“已遮挡”。
用孔洞反例约束每一层
Program.cs 中的 HoleRemainsVisible、WrongMinExposesFalseCull 和 CoarseOutsideHoleOnlyLosesCull 直接约束上述场景。下面的回归代码可与核心类一起运行,既检查正确路径,也保留错误最小值会误剔除的证据:
using DepthLab;
var depth = new float[4, 4];
for (int y = 0; y < 4; y++)
for (int x = 0; x < 4; x++) depth[y, x] = 0.2f;
depth[1, 1] = 1.0f;
var pyramid = new DepthPyramid(depth, false);
var full = new Rect(0, 0, 4, 4);
bool correctCull = pyramid.Occluded(full, 2, 0.6f, 0.001f);
bool wrongCull = 0.6f > 0.2f + 0.001f;
if (correctCull || !wrongCull)
throw new Exception("Hole regression failed");
var onePixel = new Rect(0, 0, 1, 1);
if (!pyramid.Occluded(onePixel, 0, 0.6f, 0.001f) ||
pyramid.Occluded(onePixel, 1, 0.6f, 0.001f))
throw new Exception("Coarse coverage regression failed");
更大一组检查来自 ExhaustiveCoverageAndDepthDuality:枚举八位掩码的全部 256 种取值,将八位图案重复铺入十六个像素;每种图案检查全部 100 个非空整数矩形和 3 个层级,共 76,800 组。每组用基础层逐像素比较作独立判据,要求粗层判遮挡时基础层也判遮挡,并要求正反深度给出同一结果。这没有穷尽所有十六位孔洞布局,也没有覆盖连续深度的全部取值。
测试还包含没有孔洞的正常墙面、等深度、容差附近、非法输入与输入快照隔离。2026-09-08 在 .NET 9 Release 运行,$LAB_DIR 指向包含上述两个 C# 文件和 DepthLab.csproj 的研究目录:
dotnet build "$LAB_DIR/DepthLab.csproj" -c Release --no-restore
dotnet run --project "$LAB_DIR/DepthLab.csproj" -c Release --no-build
build_exit=0 warnings=0 errors=0
normal_max=1.0 wrong_min=0.2 candidate_nearest=0.6 culled=False wrong_culled=True
reversed_min=0.0 candidate_nearest=0.4 culled=False
passed=12 failed=0 skipped=0 cases=76800 elapsed_ms=37.111
run_exit=0
耗时是整个测试进程内部检查区间的计时,不是遮挡查询的性能基准;没有据此比较 CPU 与 GPU,也没有测量实际帧率收益。
接入渲染管线前,还差哪些证明
这个核心假定调用方已经提供保守的屏幕包围矩形和候选最近深度。跨近裁剪面的包围体不能靠投影八个角点后直接取整来默认满足合同;投影退化、视口裁剪和屏外部分需要单独处理。深度也必须来自当前有效的不透明遮挡覆盖:透明表面、未覆盖的 MSAA 样本和过期历史不能未经审查就当作实心墙。
移到 Compute Shader 时,可沿用最远值归约和向外覆盖规则,但读取应保持离散单元语义,不能用普通双线性过滤替代。非二次幂纹理的边缘处理、层间同步、纹理格式误差和设备执行都尚未由本次 C# 测试验证。若为了降低查询开销只采固定数量的点,必须先证明它们覆盖了目标单元,不能以经验采样数量替换范围合同。
墙后的物体是否保留,最终取决于整片覆盖的证明。深度金字塔应该让孔洞在粗层中继续阻止错误剔除;当这使遮挡效率下降时,可以改选更细层级、收紧保守包围区域,再用实际 GPU 测量权衡成本。用更近的深度填掉孔洞,虽会提高表面上的剔除数量,却已经改变了算法允许隐藏什么。