三角形共享边的像素归属:全含边会重画,全排边会漏画
把一块半透明 UI 方片拆成两个三角形,软件绘制结果却沿对角线多出一条深色线。两片使用同一种颜色、同一份透明度,顶点也没有错位。若每个三角形都接收边上的像素,对角线就会混合两次;把所有边都改成严格不等号,又会把这条深线变成漏画的裂缝。
这里把绘制缩成一个可运行的覆盖问题:四乘四方块,逐像素统计两个三角形分别接受了几次。探针使用独立 C# 程序,不调用图形设备;它适合验证软件遮罩、离线栅格工具或自定义覆盖阶段的分区合同,不把结果包装成某个 GPU 后端的实际截图。

主视觉说明相邻面之间的采样归属;精确坐标、样本数量与覆盖结果以下方数据图为准。
一条对角线同时属于两片,颜色就会累积
方块四角为 (0,0)、(4,0)、(4,4)、(0,4),沿 (0,0) → (4,4) 切开。屏幕坐标约定为 X 向右、Y 向上,像素 (x,y) 的采样点是 (x+0.5,y+0.5)。因此四个对角线像素的中心恰好落在两片的公共边上。
对逆时针三角形,一条有向边 a→b 的边函数为 E=(bx-ax)(py-ay)-(by-ay)(px-ax)。内部位于每条边的左侧,对应正值;零值表示采样点落在边所在直线上。三条边都用 E >= 0 时,公共边上的点满足两片判定,于是得到 20 次覆盖。全部改为 E > 0,这四点又同时被拒绝,只有 12 次。
假设源 Alpha 为 0.5、背景 Alpha 为零,按普通 over 合成,一次覆盖得到 0.5,两次得到 0.5+0.5×(1-0.5)=0.75。这是重复覆盖可能变成可见接缝的代数解释,本文并未执行颜色混合测试。对不透明绘制,最终颜色可能掩盖重复,但覆盖计数的错误仍然存在,不能只用成品截图判断分区是否正确。
零值边需要一个方向约定
边界点应该恰好交给一侧。本文的 Top-left 规则在 E=0 时,仅接收向下的边,或向左的水平边,即 dy<0 || (dy==0 && dx<0)。这个符号与前面的 Y 向上、逆时针和边函数定义成套使用。换成 Y 向下的坐标后,不能原样搬用条件。
公共边在两片逆时针三角形中方向相反。一条非水平边反向后 dy 变号;水平非零边反向后 dx 变号,所以两边的零值接收条件互补。在固定方块中,下方三角形 A 的公共边是 (4,4) → (0,0),向下,四个边界样本全部归 A。上方三角形 B 拒绝它们,其余内部点各归所在的三角形。
Coverage.cs 的完整实现如下。Covers → Accept → Edge 是唯一覆盖调用链;先校验输入并统一绕序,再对采样点判断。Inclusive 与 Exclusive 只用于保留错误路径的回归对照。
public readonly record struct Point(int X, int Y);
public enum EdgeRule { Inclusive, Exclusive, TopLeft }
public static class Coverage
{
private static void Validate(Point p)
{
if (p.X < -16384 || p.X > 16384 || p.Y < -16384 || p.Y > 16384)
throw new ArgumentOutOfRangeException(nameof(p));
}
private static long Edge(Point a, Point b, long x, long y) =>
(2L * b.X - 2L * a.X) * (y - 2L * a.Y) -
(2L * b.Y - 2L * a.Y) * (x - 2L * a.X);
private static bool Accept(Point a, Point b, long x, long y, EdgeRule rule)
{
long e = Edge(a, b, x, y);
if (e != 0) return e > 0;
return rule switch
{
EdgeRule.Inclusive => true,
EdgeRule.Exclusive => false,
_ => b.Y < a.Y || (b.Y == a.Y && b.X < a.X)
};
}
public static bool Covers(Point a, Point b, Point c, Point pixel,
EdgeRule rule = EdgeRule.TopLeft)
{
Validate(a); Validate(b); Validate(c); Validate(pixel);
if (!Enum.IsDefined(rule)) throw new ArgumentOutOfRangeException(nameof(rule));
long area = Edge(a, b, 2L * c.X, 2L * c.Y);
if (area == 0) return false;
if (area < 0) (b, c) = (c, b);
long x = 2L * pixel.X + 1, y = 2L * pixel.Y + 1;
return Accept(a, b, x, y, rule) && Accept(b, c, x, y, rule) &&
Accept(c, a, x, y, rule);
}
}
实现把顶点坐标乘二,像素中心写成奇数坐标,使半像素不需要浮点表示。边函数数值变成原来的四倍,符号和零值不变。输入顶点与像素索引均限制在正负 16,384 以内,中间乘积使用 long;在该范围内,坐标差的乘积及相减远小于其上限。越界先抛错,退化或重复点构成的零面积三角形返回不覆盖。
这里主动接收两种绕序,是为了把“哪些样本属于三角形”与背面剔除分开。若渲染器需要剔除背面,应在统一绕序前保留原始面积符号并另行决策。本实现也没有计算插值权重:覆盖用的零值归属不能随意偏移属性插值所使用的边函数,否则接缝虽然消失,UV 或深度可能被另一种误差改变。

验证目标是分区,参照就不该再写一遍边函数
Program.Fixed 对三种规则分别统计漏点、重复点和总覆盖次数,把错误结果本身作为必须保持的反例。Program.Rectangles 则枚举宽高 1 至 12、平移量 -7、0、5,以及两种对角线,共 864 种矩形与划分组合。两片各自独立枚举六种顶点排列,共 36 对绕序组合;每组检查矩形内部与外围一圈像素。
参照只按 x>=left && x<right && y>=bottom && y<top 判定矩形中的像素索引,不调用边函数。实际覆盖次数必须与参照的零或一完全相等,因而重复覆盖与漏画都会失败。整数矩形边界不经过半整数中心,这个参照没有暗中引入另一套三角形边界规则。
Program.Boundaries 另外验证共线、重复点、范围极值、外部像素、越界输入和非法规则。当前整数顶点加半整数中心的合同,不会让采样点恰好落在水平或竖直边上;水平零值分支依据方向互补关系给出,但没有被这组运行输入直接触发。若扩展到子像素顶点或多重采样,必须增加对应样本,不能把现有通过数当作完整设备一致性证明。
当天运行环境为 .NET 9 Release,LAB_DIR 指向保存上述源码与项目文件的研究目录:
dotnet build "$LAB_DIR/CoverageLab.csproj" -c Release
dotnet run --project "$LAB_DIR/CoverageLab.csproj" -c Release --no-build
构建和运行退出码均为 0,构建零警告、零错误。决定性输出整理如下,耗时为整份验证程序的墙钟时间,不是光栅吞吐基准:
passed=12 failed=0 skipped=0 elapsedMs=515.6885
Inclusive fragments=20 missing=0 duplicate=4
Exclusive fragments=12 missing=4 duplicate=0
TopLeft fragments=16 missing=0 duplicate=0
rectangleCases=864 permutationPairs=36 oracleSamples=2247264
这份互补保证还依赖两片真的共享同一条边。若裁剪、投影或量化分别计算出了略有差别的端点,两次判定可能已经在测试两条不同的直线,Top-left 无法替上游补齐几何。实际接入时应让相邻面共用一致的端点变换与量化约定,再逐项验证采样位置、坐标方向和覆盖规则;不要把任意 epsilon 塞进零值条件来掩盖差异。
对开头的 UI 方片,固定样本已从重复或缺失恢复为各一次覆盖。它解决的是相邻三角形如何划分采样点,不会消除有意重叠的几何,也不会提供边缘抗锯齿。软件绘制链路可以把这条分区合同固定下来;若目标是复刻设备光栅结果,下一道验收应使用设备回读验证其子像素量化和采样规则,而不是继续增加当前整数方块的测试数量。