UI 嵌套裁剪的一列漏光:空交集必须早于像素取整
背包列表向左滚动,子区域已经越过父面板的裁剪边界,绘制命令却仍保留一列像素。这样的漏光未必需要错误的纹理或混合状态:父裁剪区间止于 10.2,子裁剪区间从 10.4 开始,中间有明确间隙;如果先把两者分别扩张到整数边界,它们就会在第 10 列重新相遇。只要后续绘制覆盖这一列,又没有精确蒙版拒绝它,这份错误裁剪框就允许内容漏出。
这里关注的是游戏 UI 命令构建器的一条 CPU 调用链:布局端给出逻辑裁剪矩形,提交端输出整数 scissor。固定输入可以复现错误框,但不等同于某款引擎的实机缺陷或已经捕获的 GPU 画面。

主视觉只说明间隙关系。下面的数值图与程序输出共同限定实际测试的坐标。
整数框重叠,不能反推逻辑区域相交
将矩形写成左闭右开的 [left,right) × [top,bottom),输入采用同一个左上原点逻辑坐标系,X 向右、Y 向下。父框为 [0,10.2) × [2,8),子框为 [10.4,20) × [2,8),缩放为 1,目标尺寸为 64×32 像素。
交集的左边界是 10.4,右边界是 10.2,左边界已经不小于右边界,所以交集为空。错误做法先取 floor(left) 和 ceil(right),把父框变成 [0,11),子框变成 [10,20),随后得到 [10,11) × [2,8)。转成左下原点后就是 (x=10,y=24,width=1,height=6)。
向外取整本来适合生成保守边界:凡是可能包含有效片元的像素,都先留给后续阶段。然而,两个扩大后的集合相交,并不能证明原集合相交。UI 层若只缓存整数框,便失去了分辨这两种情况所需的信息。
也不能由此推出“提前取整总会改变非空交集”。对有限标量,单调函数满足 floor(max(a,b))=max(floor(a),floor(b)),ceil(min(a,b))=min(ceil(a),ceil(b))。在使用同一缩放和取整规则的非空矩形上,两种顺序可以得到相同边界。当前反例揭示的是空集未被保留;保留逻辑交集及其空标志,才是需要进入接口的修复。
逻辑裁剪栈保留小数,提交边界产生整数
ClipRect.cs 的 ClipRect.Build 接收已经对齐到同一坐标空间的父子矩形。它先验证输入,再做逻辑交集;空集直接返回零面积结果。非空结果乘以正的统一缩放、裁到渲染目标范围,最后向外取整并翻转 Y 原点。Program.cs 从正常样本和回归样本调用这一入口,所有输出来自同一份核心实现。
public readonly record struct Rect(double Left, double Top, double Right, double Bottom);
public readonly record struct Scissor(int X, int Y, int Width, int Height)
{
public bool Empty => Width == 0 || Height == 0;
}
public static class ClipRect
{
public static Scissor Build(Rect parent, Rect child, double scale, int width, int height)
{
Validate(parent);
Validate(child);
if (!double.IsFinite(scale) || scale <= 0 || width <= 0 || height <= 0)
throw new ArgumentException("Invalid target");
double left = Math.Max(parent.Left, child.Left);
double top = Math.Max(parent.Top, child.Top);
double right = Math.Min(parent.Right, child.Right);
double bottom = Math.Min(parent.Bottom, child.Bottom);
// 空集在整数化之前终止,不能用扩张后的像素恢复交集。
if (left >= right || top >= bottom) return default;
double[] physical = [left * scale, top * scale, right * scale, bottom * scale];
if (physical.Any(v => !double.IsFinite(v)))
throw new ArgumentException("Physical coordinate overflow");
left = Math.Clamp(physical[0], 0, width);
top = Math.Clamp(physical[1], 0, height);
right = Math.Clamp(physical[2], 0, width);
bottom = Math.Clamp(physical[3], 0, height);
if (left >= right || top >= bottom) return default;
int x0 = (int)Math.Floor(left), y0 = (int)Math.Floor(top);
int x1 = (int)Math.Ceiling(right), y1 = (int)Math.Ceiling(bottom);
// 输入左上原点;本接口输出左下原点。
return new(x0, height - y1, x1 - x0, y1 - y0);
}
private static void Validate(Rect r)
{
double[] values = [r.Left, r.Top, r.Right, r.Bottom];
if (values.Any(v => !double.IsFinite(v)) || r.Left > r.Right || r.Top > r.Bottom)
throw new ArgumentException("Invalid rectangle");
}
}
这里的 scale 表示每个逻辑单位对应多少物理像素,不是显示器设置中的任意百分数标签;如果实际画布另有缩放,应传入合成后的比例。渲染目标的宽高也必须来自当前附件,不能用窗口尺寸代替。矩形、比例和目标尺寸应一起进入本帧命令构建,防止窗口缩放时混用两份坐标契约。
翻转原点用 height-y1,其中 y1 是已经向外取整的物理下边界;直接写成 height-top 会把框移到另一处。本接口刻意声明输出左下原点。采用左上原点的后端应在适配层使用 y0,不能再照搬翻转公式。
空矩形表示本次绘制应跳过;它与“禁用裁剪”不是同一个状态。多层裁剪时应在逻辑栈中逐层保留交集,一旦为空,当前子树的有效区域继续为空,直到弹栈恢复父层。不能把 Scissor 的整数结果送回下一层作为逻辑父框。本文只实现两层交集到提交框的切片,没有实现布局树或栈管理器。
非法数值与正常空集也分开处理。NaN、反向矩形、非正缩放、无效目标尺寸或坐标乘法溢出会抛出参数异常。调用端应拒绝对应命令并记录错误,不能捕获异常后关闭 scissor、让整张目标变为可写。先夹到目标再转整数,避免极大的有限坐标在整数转换阶段越界;乘法本身溢出则更早拒绝。

用一列错误结果守住空集语义
正常输入换成父框 [2.2,30.6) × [1.4,18.2)、子框 [10.4,40) × [4.1,12.3),比例为 1.25。逻辑交集转换到物理坐标后为 [13,38.25) × [5.125,15.375),向外取整得到左右 13、39,上下 5、16,因此输出 (13,16,26,11)。这一组同时检查交集、DPI、宽高计算和原点转换。
失败回归保留一个明确的错误实现;测试要求它产生那一列,同时要求正式入口仍为空。以下为 Program.cs 中同一组断言的关键片段,Check 负责累计结果并影响进程退出码。
Scissor Early(Rect a, Rect b)
{
Rect Round(Rect r) => new(Math.Floor(r.Left), Math.Floor(r.Top),
Math.Ceiling(r.Right), Math.Ceiling(r.Bottom));
return ClipRect.Build(Round(a), Round(b), 1, 64, 32);
}
Rect parent = new(0, 2, 10.2, 8), child = new(10.4, 2, 20, 8);
var correct = ClipRect.Build(parent, child, 1, 64, 32);
var wrong = Early(parent, child);
Check("subpixel_gap_regression", correct.Empty && wrong == new Scissor(10, 24, 1, 6));
2026-09-09 在 .NET 9 Release 控制台程序中执行。$LAB_ROOT 指向包含项目与源码的研究目录,程序在当前目录写入 result.json。
cd "$LAB_ROOT"
dotnet build ClipLab.csproj -c Release
dotnet run --project ClipLab.csproj -c Release --no-build
build: exit=0 warnings=0 errors=0 elapsed=7.26s
run: exit=0 passed=15 failed=0 skipped=0 elapsed=45.2625ms
correct_empty=true wrong_scissor=(10,24,1,6)
normal_scissor=(13,16,26,11)
fixed_pairs=1521 false_nonempty_in_wrong_path=60
构建耗时取构建输出;运行耗时由程序内部计时器记录,包含断言输出和枚举,不含进程启动及 JSON 写盘。15 项测试覆盖相切、零宽高、完全越界、目标夹取、两种比例以及非法输入。枚举则把父右边界和子左边界各取 i/4,i=1..39,共 39×39 组:所有空交集必须仍为空,非空框必须覆盖真实 X 区间。错误路径在其中制造 60 组非空结果,这个数量只属于该固定集合,不能当成线上发生概率。
scissor 仍然不是亚像素蒙版
这次修复保证空集不会复活,也为有效区域生成保守整数包围框。非空边界仍可能向外扩张不到一像素;若产品要求严格的亚像素边缘或圆角裁剪,必须另有明确的覆盖规则,由 Shader、模板或几何裁剪实现。向内取整虽然能缩小框,却可能删除本来应该覆盖的边缘,不能作为无条件替换。
旋转后的裁剪形状、非均匀坐标映射、MSAA 样本位置与抗锯齿覆盖不在本次 CPU 验证内。尤其是旋转矩形的轴对齐包围框,仅适合粗裁剪,不能直接充当原形状。接入引擎之后仍需以目标后端的实际片元结果验收,本文不据此声称帧率改善或像素级渲染已经验证。
对于开头的滚动列表,命令构建器已经可以在整数化之前停止那次无交集绘制。值得保留的状态是逻辑有效区域,整数框则是面向当前目标的派生结果;精确覆盖另有明确的消费接口。这样改动后,布局层不会为了方便提交而丢失空集,渲染后端也不必猜测一列整数交集究竟代表真实内容还是边界扩张。