· 5 分钟阅读 · 2026 字
旋转 UI 裁剪后,透明角落为何仍能点中
边长为二的正方形绕中心旋转 45°,屏幕包围盒的半宽变成 √2。指针位于 (1.2,1.2) 时,包围盒检查通过,但逆旋转后的局部横坐标约为 1.697,已经越过矩形右边界。若列表的裁剪区域按旋转矩形绘制,输入却只检查包围盒,玩家就能从看不见内容的角落选中列表项。
这个反例不需要控件正在移动,也不需要事件晚到一帧。渲染和输入可以使用同一时刻的矩阵,仍然因消费了不同形状而分歧。C# 探针把问题限制在二维仿射变换后的矩形裁剪链:按钮自己的几何命中已通过,现在要判断它是否被祖先裁掉。

包围盒只负责缩小候选集合
正方形的局部范围为 [-1,1]²,旋转后的实际覆盖满足 |x|+|y|≤√2;屏幕包围盒只检查 |x|≤√2 且 |y|≤√2。点 (1.2,1.2) 满足后者,但两坐标绝对值之和为 2.4,大于 √2。误差来自用较大集合替代了原集合,与浮点容差无关。
把所有祖先的屏幕包围盒先相交,可以便宜地排除大量候选,却仍不能恢复旋转边。只有这个粗筛结果为真时,才继续做每个裁剪器的精确检查。裁剪链表达的是交集:任何一个祖先拒绝,子项都不可命中。
固定的第二个输入是 (0.8,0)。它在旋转正方形内,却在轴对齐的父裁剪器 [-0.5,0.5]² 外。只检查最近的旋转裁剪器会返回真,完整链必须返回假。将子项矩形与祖先矩形直接比较也不成立,因为它们未必共享局部坐标系。

指针不变,每一级使用自己的逆变换
ClipRect.LocalToScreen 已经包含从该裁剪器到屏幕的完整祖先变换。System.Numerics 的行向量约定下,子级局部矩阵在前、父级矩阵在后相乘。输入层先把窗口坐标、画布缩放和偏移统一到这个屏幕坐标系,再调用裁剪函数;不能把设备像素和画布逻辑单位直接混用。
调用链为 Program.Hit → ClipChain.Contains → Valid。实现保存在 .tmp/daily-labs/2026-10-27-2000/ClipChain.cs,输入是屏幕点和裁剪器快照:
using System.Numerics;
public readonly record struct ClipRect(Vector2 Min, Vector2 Max, Matrix3x2 LocalToScreen);
public static class ClipChain
{
public static bool Contains(Vector2 screen, ReadOnlySpan<ClipRect> clips)
{
if (!Finite(screen)) return false;
foreach (var clip in clips)
{
if (!Valid(clip, out double determinant))
return false;
var m = clip.LocalToScreen;
double x = (double)screen.X - m.M31, y = (double)screen.Y - m.M32;
double localX = (x * m.M22 - y * m.M21) / determinant;
double localY = (y * m.M11 - x * m.M12) / determinant;
if (!double.IsFinite(localX) || !double.IsFinite(localY) ||
localX < clip.Min.X || localX > clip.Max.X ||
localY < clip.Min.Y || localY > clip.Max.Y)
return false;
}
return true;
}
private static bool Valid(ClipRect clip, out double determinant)
{
determinant = 0;
var m = clip.LocalToScreen;
if (!Finite(clip.Min) || !Finite(clip.Max) ||
clip.Min.X >= clip.Max.X || clip.Min.Y >= clip.Max.Y ||
!float.IsFinite(m.M11) || !float.IsFinite(m.M12) ||
!float.IsFinite(m.M21) || !float.IsFinite(m.M22) ||
!float.IsFinite(m.M31) || !float.IsFinite(m.M32))
return false;
double a = m.M11, b = m.M12, c = m.M21, d = m.M22;
determinant = a * d - b * c;
double scale = Math.Sqrt((a * a + b * b) * (c * c + d * d));
return scale > 0 && Math.Abs(determinant) > 1e-6 * scale;
}
private static bool Finite(Vector2 v) => float.IsFinite(v.X) && float.IsFinite(v.Y);
}
对矩阵的线性部分记 a=M11,b=M12,c=M21,d=M22,则屏幕相对平移后的坐标满足 x=a·u+c·v、y=b·u+d·v。实现用行列式直接解出局部坐标 (u,v),随后与这个裁剪器的局部矩形比较。每次都从原始屏幕点开始,不能把上一级求得的局部点再次当成屏幕点输入,否则会重复施加变换。
矩阵元素和接口仍使用单精度;相减、行列式和除法使用双精度,避免极小或极大统一缩放令中间乘积过早下溢或溢出。它无法恢复输入矩阵生成前已经损失的精度。近共线的两条变换基向量也会放大误差,因此用 |det|/(|basisX||basisY|)>10^-6 作拒绝条件。这是基向量夹角的检查,不是完整条件数估计;高度不均匀缩放仍需要按项目坐标范围另定约束。
零面积矩形、非有限值、奇异矩阵和近共线基均拒绝命中。若只在矩阵无效时跳过该祖先,原本应被隐藏的子项反而会获得输入权限。空链返回真表示“没有裁剪约束”,不表示控件自身几何已经命中。矩形边界采用包含式比较,相邻控件共边时的最终归属仍由输入层的排序和消费规则决定。
用另一种几何算法核对结果
Program.cs 的 aabb-false-positive 固定检查开头反例,ancestor-intersection 固定检查父裁剪器排除子项。失败回归 invalid-ancestor-closed 在有效子裁剪器之后加入零缩放祖先,要求整链返回假;它防止非法输入变成放宽裁剪的机会。
transformed-halfplanes-reference 使用种子 27102720 生成 10,000 组点与矩形变换,覆盖旋转、非均匀缩放、镜像、剪切和平移。对照算法先变换四个顶点,再检查四条边的叉积符号,不复用逆矩阵算法。两种方法在本组输入上没有分歧;屏幕包围盒却额外接受了 857 个实际在四边形外的点。这个数量只描述固定样本,不用于估计玩家点击的误判概率。
2026-10-27 在 .NET 9 Release 下执行,$LAB_ROOT 表示本文探针目录:
$ cd $LAB_ROOT
$ dotnet build ClipLab.csproj -c Release --nologo
Build succeeded. 0 Warning(s), 0 Error(s)
exit code: 0
$ dotnet run --project ClipLab.csproj -c Release --no-build
passed=25 failed=0 skipped=0 samples=10000 elapsed_ms=86.6257
fixed_input.screen=(1.2,1.2)
fixed_input.local_xy=(1.6970563,0.00000000987562)
fixed_input.aabb_hit=true clip_hit=false
ancestor_sample.screen=(0.8,0) child_hit=true chain_hit=false
mismatches=0 aabbFalsePositives=857
exit code: 0
输出按 run.json 字段整理。25 项还包含边界点、负缩放、空链、非法数值、组合祖先矩阵,以及 10^-30 和 10^30 的统一缩放。测试耗时记录本次正确性检查,不代表 UI 帧预算;探针也没有运行引擎遮罩或 GPU 光栅化。
命中形状必须属于布局快照
这里支持的实现决策是:屏幕包围盒保留在候选筛选层,精确裁剪使用各祖先的局部矩形与完整变换,输入和渲染共同消费同一修订的裁剪链。布局系统拥有这份快照;命中层不能临时读取一部分新矩阵、一部分旧边界。否则几何算法正确,列表滚动或重挂父节点时仍会产生一帧分歧。
探针为便于复核,每次查询都检查并求解各级变换,成本随裁剪深度线性增长。生产实现可在布局修订提交时验证矩阵、缓存逆变换和粗筛包围盒;祖先变换、裁剪边界或父子关系改变,都必须使依赖快照失效。缓存键只绑定子项位置会漏掉祖先变化。
矩形合同也需要与实际渲染路径一致。若画面本来只用轴对齐 Scissor,输入使用旋转矩形会比画面更窄;若画面使用圆角、透明纹理或任意路径遮罩,仅靠本实现又会过宽。后两类需要对应的距离、透明度或路径判定。确定渲染究竟裁掉哪些点,再选择输入几何,才能让开头那个不可见角落真正失去点击资格。