做上位机、做视觉定位、做绘图控件、做工艺流程编辑器,甚至做那个被产品经理随口提一句"加个拖拽对齐"的功能,只要沾上图形界面,图形位置计算基本都躲不开。C# 里这块东西听着简单——两点距离、两条线的夹角、矩形和圆碰没碰上,初中数学水平就够了。可真写进项目,坑往往不在公式本身,而在坐标系方向反了、角度单位混了、浮点比较差那么一点点、还有"算出来相交之后该怎么办"这几件事上。我这些年反复重写这几段代码,从最开始每次现推公式,到后来攒出一个能直接抄的静态工具类,中间的弯路够写一篇。
下面把两点位置、两线夹角、矩形与圆的交互这几块拆开聊:公式怎么来的、代码怎么写、参数为什么这么取,还有我实际踩过的坑。刚接触图形交互的 C# 开发者能直接拿去用,做过几年的人也可以拿它当一份速查表,尤其是容差和分离位移那两节,很多"偶发穿模""贴边抖动"的老毛病根子都在那儿。
1. 动手之前,先把坐标系和数值类型定死
1.1 屏幕坐标的 Y 轴朝下,这件事必须先统一口径
图形位置计算出错,十个里有六个是坐标系没统一。数学课本里的直角坐标系,Y 轴朝上,逆时针是正方向;而 Windows 窗体、WPF、大部分绘图库、Canvas 出来的坐标系,Y 轴朝下,顺时针才是视觉上的正转。这两种习惯混在一个项目里,表现出来就是"角度算出来是 330 度,但我肉眼看到明明是往左上 30 度",然后就开始怀疑公式。
我的处理办法是在项目开头写一段注释把约定钉死,并且所有对外暴露的角度接口都加一个screenYDown参数,默认 true。这样调用方一眼就知道这个函数是按屏幕坐标算的。内部的向量运算不需要区分坐标系,因为加减乘除不涉及方向翻转,只有涉及"角度""法线朝向""顺逆时针"的地方才需要转换。
具体到转换,Atan2出来的角度在屏幕坐标下要先对 Y 取反:
double rad = Math.Atan2(screenYDown ? -dy : dy, dx);只改这一行,上面所有角度相关的函数就都对齐了。别在调用处到处手动加负号,那种写法过两个月自己都看不懂。
1.2 弧度与角度、以及角度归一化的三种写法
C# 的Math系列三角函数全部用弧度,这一点没得商量。麻烦在于业务侧永远用角度:旋转多少度、装配偏角多少度、UI 上显示的朝向多少度。所以每个项目我都会写一对转换函数,并且规定"内部一律弧度,只在显示和配置读写时转角度"。
public const double Deg2Rad = Math.PI / 180.0; public const double Rad2Deg = 180.0 / Math.PI;角度归一化看着不起眼,实际很容易写错。常用的目标区间有两个:[0, 360)用于显示方位,(-180, 180]用于表示相对偏转。前者用取模加补正,后者用循环加减:
public static double NormalizeDeg360(double deg) { deg %= 360.0; if (deg < 0) deg += 360.0; return deg; } public static double NormalizeDeg180(double deg) { deg %= 360.0; if (deg > 180.0) deg -= 360.0; if (deg <= -180.0) deg += 360.0; return deg; }之前有个项目用while循环归一化,输入是个因为除零产生的1e17级别的垃圾值,循环跑了几十亿次直接把界面卡死。用取模就不会有这个问题,性能也稳。这是一个很典型的"公式对但工程上不安全"的例子,凡是可能要处理外部输入的地方,取模永远比循环靠谱。
1.3 float 还是 double,容差 eps 到底取多少
C# 里图形相关的既有 API 大量用float(PointF、SizeF、System.Numerics.Vector2),但新写的计算层我建议统一用double。原因是float只有约 7 位有效数字,在 1000 像素量级的坐标上分辨率大概 1e-4,听起来够用;但一旦坐标来自 CAD 的毫米单位建模,数值可能到 1e6 甚至 1e7,此时float的间隔就到了 0.06 到 0.6,做"点在线上"这种判定已经完全不可靠。
容差 eps 不能拍脑袋写1e-6。合理的做法是按数值量级估:如果你的坐标在 0 到 10000 之间、业务精度要求到 0.01 单位,那 eps 就该取 1e-3 左右,而不是 1e-6。取太小的后果是本来重合的两条线判成不相交,用户看到的是一条本该贴合的线有 0.0001 的缝。我一般的经验值是:几何判等 eps 取业务精度的 1/10,方向判定 eps 取 1e-9 到 1e-12 之间,因为叉积的量级是长度平方,本身数值就大。
注意:所有
==比较浮点数的代码在评审时必须拦下来,统一换成带 eps 的NearlyEqual。这条规矩比任何优化都值钱。
2. 两点之间:距离、方位角与一个够用的向量结构
2.1 距离计算:为什么一大半场景应该用平方距离
两点距离就是勾股定理,没什么好讲的:
public static double Distance(double x1, double y1, double x2, double y2) { double dx = x2 - x1, dy = y2 - y1; return Math.Sqrt(dx * dx + dy * dy); }真正值得说的是平方距离。Math.Sqrt是相对贵的运算,而在大量场景里我们根本不需要真实距离,只需要比较大小:找最近的点、判断是否在半径内、按距离排序。这些都满足单调性,用平方距离完全等价:
public static double DistanceSquared(double x1, double y1, double x2, double y2) { double dx = x2 - x1, dy = y2 - y1; return dx * dx + dy * dy; }判断圆是否命中,写成DistanceSquared(...) <= r * r就行,省掉一次开方。我在一个需要每帧对 3000 个图元做命中测试的编辑器里把这处改掉,帧耗时从 11ms 降到 7ms,改动量不到十行。数量级不大但胜在零风险。
还有一个容易忽略的坑:dx * dx + dy * dy在极端情况下会溢出。如果坐标来自传感器原始值,可能出现 1e200 这种量级,平方后变无穷。稳妥写法是先判断量级,或者直接用Math.Hypot式的实现(.NET 没有内置,得自己写缩放版本)。一般业务碰不到,但做数据清洗工具时是真会遇到。
2.2 方位角:Atan2 的参数顺序和零向量保护
方位角是"从 A 看向 B,朝向是多少度"。标准写法:
public static double AzimuthDeg(double x1, double y1, double x2, double y2, bool screenYDown = true) { double dx = x2 - x1; double dy = y2 - y1; if (dx == 0 && dy == 0) return 0; // 零向量,返回约定值 double rad = Math.Atan2(screenYDown ? -dy : dy, dx); double deg = rad * Rad2Deg; return NormalizeDeg360(deg); }Math.Atan2(y, x)的参数顺序是y 在前 x 在后,这个和直觉相反,我见过不止一个人写成Atan2(dx, dy)然后困惑为什么角度整体偏了 90 度。记住它返回的是(x, y)这个点相对原点的极角,那 y 在前就顺理成章了。
零向量必须单独处理。虽然Atan2(0, 0)在 .NET 里返回 0 不抛异常,但语义上"从 A 到 B 的方向"在 A=B 时根本没有定义,业务侧拿到 0 度可能会误判成"正右方"。我倾向于返回一个约定的double.NaN让上层显式处理,或者在文档里写死"零向量返回 0",两种都行,但必须写清楚。这个细节在拖拽起点和终点重合时会立刻暴露出来。
如果要把方位角反过来变成方向向量,注意屏幕坐标下也要翻转 Y:
public static (double dx, double dy) FromAzimuthDeg(double deg, bool screenYDown = true) { double rad = deg * Deg2Rad; double dx = Math.Cos(rad); double dy = Math.Sin(rad); return screenYDown ? (dx, -dy) : (dx, dy); }正反两套函数必须成对使用同一套坐标系约定,不然就会出现"设进去 30 度,读出来 330 度"的鬼故事。
2.3 一个精简的 Vec2 结构体,比到处传四个 double 强太多
早期我图省事,所有函数签名都是(double x1, double y1, double x2, double y2)。代码写到第三个模块就崩溃了——参数顺序记不住,PointInRect(p.X, p.Y, min.X, min.Y, max.X, max.Y)这种八参数调用,编译器不会告诉你哪两个写反了。
后来统一改成只读结构体:
public readonly struct Vec2 { public readonly double X; public readonly double Y; public Vec2(double x, double y) { X = x; Y = y; } public static Vec2 operator +(Vec2 a, Vec2 b) => new Vec2(a.X + b.X, a.Y + b.Y); public static Vec2 operator -(Vec2 a, Vec2 b) => new Vec2(a.X - b.X, a.Y - b.Y); public static Vec2 operator *(Vec2 a, double k) => new Vec2(a.X * k, a.Y * k); public static Vec2 operator /(Vec2 a, double k) => new Vec2(a.X / k, a.Y / k); public double LengthSquared => X * X + Y * Y; public double Length => Math.Sqrt(LengthSquared); public Vec2 Normalized { get { double len = Length; return len < 1e-12 ? new Vec2(0, 0) : new Vec2(X / len, Y / len); } } public static double Dot(Vec2 a, Vec2 b) => a.X * b.X + a.Y * b.Y; public static double Cross(Vec2 a, Vec2 b) => a.X * b.Y - a.Y * b.X; }几个设计决定解释一下。第一,用readonly struct而不是class,是为了避免堆分配和 GC 压力,批量计算时差异很明显。第二,字段只读、没有属性 setter,避开可变值类型带来的"改了副本原对象没变"的经典错误。第三,Normalized里对零长度做保护,否则会出现NaN然后在几十行之外炸掉,排查成本极高。
没有直接用System.Numerics.Vector2的原因是它只有float精度,而且 X/Y 是字段,项目里有些地方确实需要double。如果你的业务全在像素量级,用官方那个完全没问题,还自带 SIMD 加速。
3. 两线夹角:先搞清楚你要的是哪个角
"两条线的夹角"这句话至少有三种含义:两条直线的无向夹角(0 到 90 或 0 到 180)、两条射线的方向角之差(有正负,-180 到 180)、两条线段延长后的交点内角。业务上说不清楚要哪种,代码就必然返工。我的习惯是先问一句"这个角度用来做什么",答案决定了算法。
3.1 点积反余弦:最通用的无向夹角
给定两条线的方向向量,夹角用点积公式:
public static double AngleBetweenDeg(Vec2 d1, Vec2 d2) { double len1 = d1.Length, len2 = d2.Length; if (len1 < 1e-12 || len2 < 1e-12) return 0; double cos = Vec2.Dot(d1, d2) / (len1 * len2); cos = Math.Max(-1.0, Math.Min(1.0, cos)); // 关键:钳位 return Math.Acos(cos) * Rad2Deg; }那个Math.Max/Min钳位是必须的。浮点误差会让cos比 -1 小一点点,比如 -1.0000000000000002,Acos直接返回NaN,然后NaN会传染到后面所有计算,最后表现成一个莫名其妙的"图形消失了"。这个问题在两条线几乎重合或几乎相反时必然出现,一定要写。
另外理解一下结果范围:Acos返回 0 到 π,所以拿到的是 0 到 180 度的无向夹角。如果业务要的是"两条直线的锐角夹角",还得再折叠一次:
double a = AngleBetweenDeg(d1, d2); if (a > 90.0) a = 180.0 - a;有个小性能提示:如果你只是比较两个夹角的大小(比如判断哪个更接近目标角度),用acos之前的cos值比较就行,但要注意余弦在[0, π]上单调递减,大小关系要反过来。
3.2 方向角相减:带正负的偏转角
工业设备里更常见的需求是"从当前朝向转到目标朝向,需要转多少度,顺时针为正"。这时候用方向角相减:
public static double DeltaAngleDeg(Vec2 from, Vec2 to) { double a = Math.Atan2(from.Y, from.X); double b = Math.Atan2(to.Y, to.X); double d = b - a; // 归到 (-π, π] d = Math.Atan2(Math.Sin(d), Math.Cos(d)); return d * Rad2Deg; }Atan2(Sin(d), Cos(d))这个技巧比循环加减 2π 更简洁也更安全,任何输入都能一次到位,不会因为输入是 1e15 而卡住。这是我很喜欢的一个写法,值得记住。
拿到DeltaAngleDeg之后,业务上就能直接做最短路径旋转:结果为正是逆时针(数学坐标下),要转的绝对量就是Math.Abs(d)。如果结果落在 ±180 附近,说明两个方向近乎相反,机械结构上可能要走另一侧,这时需要额外的策略,比如"优先保持上次旋转方向"。
3.3 叉积定号:区分左转还是右转
点积只能告诉你夹角大小,判断在左侧还是右侧要靠叉积。二维叉积定义:
public static int TurnSign(Vec2 d1, Vec2 d2, double eps = 1e-9) { double c = Vec2.Cross(d1, d2); if (c > eps) return 1; if (c < -eps) return -1; return 0; }在屏幕坐标(Y 向下)里,叉积为正对应视觉上的顺时针;在数学坐标里则对应逆时针。这个符号含义还是那句话,必须和坐标系约定绑死,我建议直接封装成Clockwise/CounterClockwise枚举返回,而不是裸露1/-1,可读性差太多了。
叉积的 eps 要特别注意量级。叉积等于|a||b|sinθ,如果两条线长度都是 100,那么sinθ = 1e-9时叉积才 1e-5。所以判断"共线"的阈值不应该写死 1e-9,而应该和长度挂钩:
double eps = 1e-9 * d1.Length * d2.Length;这样不同长度的线段才有可比的判定标准。这是我在一个地图标注项目里被坑出来的:短线段共线判定太松,长线段太严,同一份代码表现不一致。
3.4 线段相交与交点:参数化求解是最稳的写法
求两条线段是否相交、交点在哪,用参数形式最干净。设第一条线段为P + t·r(r = P2 - P),第二条为Q + u·s(s = Q2 - Q),联立解出:
public static bool SegmentIntersect(Vec2 p1, Vec2 p2, Vec2 q1, Vec2 q2, out Vec2 hit) { hit = default; Vec2 r = p2 - p1; Vec2 s = q2 - q1; double denom = Vec2.Cross(r, s); // 平行或共线:本函数不处理重叠情况,交由上层判断 if (Math.Abs(denom) < 1e-12 * r.Length * s.Length) return false; Vec2 qp = q1 - p1; double t = Vec2.Cross(qp, s) / denom; double u = Vec2.Cross(qp, r) / denom; if (t < 0.0 || t > 1.0 || u < 0.0 || u > 1.0) return false; hit = p1 + r * t; return true; }推导过程:P + t·r = Q + u·s,两边叉乘s消掉u·s项,得cross(P - Q, s) + t·cross(r, s) = 0,所以t = cross(Q - P, s) / cross(r, s)。注意符号,Q - P就是上面的qp。这里我一开始就写成cross(qp, s)而不是cross(P - Q, s),省得后面符号翻来覆去。
t和u都在[0,1]内才算线段相交,这是"线段"和"直线"的唯一区别。做图形编辑器时经常要判断"无限延长线相交",那就去掉这个范围检查,只留平行判断。
平行判断里的Math.Abs(denom) < eps有个陷阱:如果两条线段都很短,比如长度都是 0.001,那么cross的最大值也就 1e-6,用固定 1e-12 判平行没问题;但如果长度都是 1e6,cross正常值动辄 1e12,1e-12 的相对误差可能就超过阈值了。所以还是那个原则——阈值随量级缩放。
另外必须明确:这个实现不处理共线重叠。两条平行且共线的线段,"是否相交"的答案是"有一段重叠",这在图形编辑器里很常见(比如两条边贴在一起)。要么在上面加一段共线处理,要么在文档里写清"平行返回 false,重叠由调用方处理"。我倾向于后者加一个独立的SegmentOverlap函数,职责单一好维护。
4. 矩形和圆的交互:判定只是第一步
4.1 点与矩形:轴对齐和旋转两套逻辑
轴对齐矩形(AABB)的判定最简单,四个比较:
public static bool PointInAabb(Vec2 p, Vec2 min, Vec2 max) { return p.X >= min.X && p.X <= max.X && p.Y >= min.Y && p.Y <= max.Y; }注意min和max的约定:一定是min.X <= max.X、min.Y <= max.Y。如果构造矩形时用户是从右下往左上拖,这两个条件就不成立,判定直接失效。稳妥做法是加一个规范化函数,构造时就交换,别让这个约束扩散到几十个调用点。
旋转矩形(OBB)用"逆旋转到局部坐标"的思路,把问题转化成 AABB:
public static bool PointInObb(Vec2 p, Vec2 center, double halfW, double halfH, double angleRad) { double cos = Math.Cos(angleRad), sin = Math.Sin(angleRad); double dx = p.X - center.X, dy = p.Y - center.Y; // 逆旋转 double lx = dx * cos + dy * sin; double ly = -dx * sin + dy * cos; return Math.Abs(lx) <= halfW && Math.Abs(ly) <= halfH; }这里halfW/halfH是半宽半高,不是全宽全高。这个命名很重要,用width/height表示半宽是万恶之源,我自己就因此写错过一次,结果所有图形尺寸都只有设计值的一半。用halfW/halfH名字里就带着语义,想错都难。
旋转矩阵这块,逆旋转的公式是x' = x·cosθ + y·sinθ,y' = -x·sinθ + y·cosθ。很多人会写成把 cos/sin 的位置对调的形式,两者在正负号上不同,最容易在角度是个小量(比如 5 度)时看不出来,一旦角度到 45 度以上立刻露馅。测试的时候一定要用 30 度、45 度、90 度这种能暴露符号错误的角。
4.2 圆与矩形相交:最近点法是万金油
圆和矩形判定,标准解法是找矩形上离圆心最近的点,然后比较距离。这个方法对 AABB 和 OBB 都适用(OBB 先转局部坐标),而且顺带能算出穿透方向和深度,非常适合做碰撞响应。
public static bool CircleHitAabb(Vec2 c, double r, Vec2 min, Vec2 max, out Vec2 nearest, out double dist) { double nx = Math.Max(min.X, Math.Min(c.X, max.X)); double ny = Math.Max(min.Y, Math.Min(c.Y, max.Y)); nearest = new Vec2(nx, ny); Vec2 d = c - nearest; dist = d.Length; return dist <= r; }Math.Max(min, Math.Min(c, max))这个"钳位"写法是核心,它把圆心坐标夹到矩形范围内,得到的点必然在矩形上或矩形内。如果圆心本来就在矩形里面,钳位后还是它自己,dist就是 0,<= r恒成立——这正是我们要的结果。
这个函数有几个变体值得顺手写好:
| 判定需求 | 核心思路 | 典型误用 |
|---|---|---|
| 圆与矩形是否重叠 | 最近点距离 ≤ 半径 | 用圆心到矩形中心距离判断,误判严重 |
| 点是否在圆内 | 平方距离 ≤ 半径平方 | 开方后比较,多一次 sqrt |
| 点是否在圆环带 | 两个平方距离比较 | 用外半径减内半径做单一比较 |
| 圆与圆是否重叠 | 圆心距 ≤ 半径和 | 忘记处理包含关系 |
圆环带的写法单独说一下,因为很容易写错:
public static bool PointInAnnulus(Vec2 p, Vec2 center, double rInner, double rOuter) { double d2 = (p - center).LengthSquared; return d2 >= rInner * rInner && d2 <= rOuter * rOuter; }注意是"两个边界各自平方后比较",不能写成Math.Abs(d - rInner) <= rOuter - rInner那种,那个表达式在几何上完全没有意义。
4.3 相交之后的分离位移:这一步最容易被漏掉
判定"碰上了"只是一半工作,真做拖拽、物理模拟、碰撞响应时,还需要把圆推开到刚好不相交的位置。用最近点法可以顺手算出来:
public static bool ResolveCircleAabb(Vec2 c, double r, Vec2 min, Vec2 max, out Vec2 push) { push = default; double nx = Math.Max(min.X, Math.Min(c.X, max.X)); double ny = Math.Max(min.Y, Math.Min(c.Y, max.Y)); Vec2 nearest = new Vec2(nx, ny); Vec2 d = c - nearest; double distSq = d.LengthSquared; if (distSq > r * r) return false; double dist = Math.Sqrt(distSq); if (dist > 1e-9) { // 圆心在矩形外部:沿最近点指向圆心的方向,推到表面 + 半径处 push = d * ((r - dist) / dist); } else { // 圆心在矩形内部:从最近的边推出去 double dl = c.X - min.X, dr = max.X - c.X; double dt = c.Y - min.Y, db = max.Y - c.Y; double m = Math.Min(Math.Min(dl, dr), Math.Min(dt, db)); if (m == dl) push = new Vec2(-(dl + r), 0); else if (m == dr) push = new Vec2(dr + r, 0); else if (m == dt) push = new Vec2(0, -(dt + r)); else push = new Vec2(0, db + r); } return true; }两段分支的区别在于:圆心在矩形外时,穿透深度是r - dist,方向是d的单位向量;圆心在矩形内时,最近点就是圆心本身,dist为 0,方向信息丢失了,只能退化成"找最近的边推出去",此时穿透深度是"到该边的距离 + 半径"。
那个m == dl的浮点比较看着刺眼,但这里是安全的:m就是由这四个数算出来的Math.Min结果,比较的是完全相同的值,不涉及运算误差。如果换成在别处重新算一遍dl,那就必须用 eps 比较了。
注意:如果同一个圆同时和多个矩形相交,逐个
Resolve会出现"推开 A 又被 B 挤回去"的抖动。做法是把所有push累加后一次应用,或者按穿透深度排序、只处理最深的那一个。我倾向累加,抖动更小。
4.4 旋转矩形的圆判定:坐标系变换解决一切
OBB 和圆的交互,思路固定——把圆心逆旋转到矩形的局部坐标系,问题就变成 AABB 与圆,算完再把结果旋转回去:
public static bool ResolveCircleObb(Vec2 c, double r, Vec2 center, double halfW, double halfH, double angleRad, out Vec2 pushWorld) { pushWorld = default; double cos = Math.Cos(angleRad), sin = Math.Sin(angleRad); double dx = c.X - center.X, dy = c.Y - center.Y; // 圆心转到局部坐标 Vec2 local = new Vec2(dx * cos + dy * sin, -dx * sin + dy * cos); Vec2 min = new Vec2(-halfW, -halfH); Vec2 max = new Vec2(halfW, halfH); if (!ResolveCircleAabb(local, r, min, max, out Vec2 pushLocal)) return false; // 位移向量转回世界坐标(正旋转) pushWorld = new Vec2(pushLocal.X * cos - pushLocal.Y * sin, pushLocal.X * sin + pushLocal.Y * cos); return true; }这里最容易错的是旋转和逆旋转的符号。我建议写两个小函数ToLocal和ToWorld,成对使用,并在单元测试里用一个 90 度旋转的案例验证:世界坐标(1,0)的偏移,在角度 90 度的矩形局部坐标系里应该变成(0,-1)(屏幕坐标下)。这种测试写起来只要五行,但能挡住后面所有的符号错误。
还有个常见需求是"旋转矩形的四个角点",这几乎是所有图形编辑器的基础:
public static Vec2[] ObbCorners(Vec2 center, double halfW, double halfH, double angleRad) { double cos = Math.Cos(angleRad), sin = Math.Sin(angleRad); Vec2[] local = { new Vec2(-halfW, -halfH), new Vec2(halfW, -halfH), new Vec2(halfW, halfH), new Vec2(-halfW, halfH) }; var result = new Vec2[4]; for (int i = 0; i < 4; i++) { result[i] = new Vec2( center.X + local[i].X * cos - local[i].Y * sin, center.Y + local[i].X * sin + local[i].Y * cos); } return result; }角点顺序要固定(我习惯从左上开始顺时针),因为后面的命中测试、包围盒计算都依赖这个顺序。顺序变了不会报错,只会让结果诡异。
5. 拼装成一个可直接抄的工具类
5.1 完整的 GeometryUtil 骨架
前面几节零散写了各种函数,实际项目里我把它们收在一个静态类里,按功能分组。下面是一个精简但够用的版本,可以直接拿去改成自己的命名空间:
public static class GeometryUtil { public const double Deg2Rad = Math.PI / 180.0; public const double Rad2Deg = 180.0 / Math.PI; public const double Eps = 1e-9; public static bool NearlyEqual(double a, double b, double eps = Eps) => Math.Abs(a - b) <= eps; public static double NormalizeDeg360(double deg) { deg %= 360.0; return deg < 0 ? deg + 360.0 : deg; } public static double NormalizeDeg180(double deg) { deg %= 360.0; if (deg > 180.0) deg -= 360.0; if (deg <= -180.0) deg += 360.0; return deg; } public static Vec2 ClosestPointOnSegment(Vec2 p, Vec2 a, Vec2 b) { Vec2 ab = b - a; double len2 = ab.LengthSquared; if (len2 < 1e-18) return a; double t = Vec2.Dot(p - a, ab) / len2; t = t < 0 ? 0 : (t > 1 ? 1 : t); return a + ab * t; } public static double PointToSegmentDistance(Vec2 p, Vec2 a, Vec2 b) => (p - ClosestPointOnSegment(p, a, b)).Length; public static bool SegmentIntersect(Vec2 p1, Vec2 p2, Vec2 q1, Vec2 q2, out Vec2 hit) { hit = default; Vec2 r = p2 - p1, s = q2 - q1; double denom = Vec2.Cross(r, s); double scale = r.Length * s.Length; if (Math.Abs(denom) < 1e-12 * scale) return false; Vec2 qp = q1 - p1; double t = Vec2.Cross(qp, s) / denom; double u = Vec2.Cross(qp, r) / denom; if (t < 0 || t > 1 || u < 0 || u > 1) return false; hit = p1 + r * t; return true; } }ClosestPointOnSegment是隐藏的高频函数,很多需求本质上都是它:光标吸附到线段、点到折线的最近距离、判断点击是否落在一条粗线上(距离 ≤ 线宽一半)。t的钳位保证结果落在端点之间的线段上,不做钳位就变成了"点到无限直线"的距离,两者在吸附场景里的表现完全不同——不钳位时光标靠近延长线也会被吸过去,用户会觉得莫名其妙。
5.2 参数是怎么算出来的:一个具体案例
拿"判断鼠标点击是否命中一条宽度为 4 的连线"来说,走一遍完整流程,能看清各参数怎么来。
第一步,拿到点击点p和线段两端a、b,算出最近点q = ClosestPointOnSegment(p, a, b)。第二步,算d = |p - q|。第三步,和线宽的一半比较。线宽 4 像素,那么视觉上能接受点击的范围其实比 2 像素大,因为人眼和鼠标都有误差,实际取2.5到3.5之间比较舒服。我一般取线宽 / 2 + 2,即 4,这是个经验值。
如果线是倾斜的,还有一个额外问题:线段的两个端点附近是圆头还是平头。ClosestPointOnSegment的钳位效果等于圆头,端点周围半圆范围内也会命中。如果视觉上是平头(LineCap.Flat),就要额外判断点击点在线段方向的投影是否超出[0, 1]:
Vec2 ab = b - a; double t = Vec2.Dot(p - a, ab) / ab.LengthSquared; if (t < 0 || t > 1) return false; // 平头,超出端点不算这个细节在画流程图的工具里非常影响手感:圆头模式下连线端点附近点击特别好中,平头模式下用户会抱怨"明明点在线上了却选不中"。选哪个取决于视觉呈现,但代码里必须显式处理,不能靠默认行为。
5.3 批量计算的写法和一个实测数据
当图元数量上到几千,循环体的写法就值得推敲了。几个我实测有效的点:
一是把Vec2[]换成Span<Vec2>或直接传ReadOnlySpan<Vec2>,避免方法调用时的防御性拷贝。二是把List<T>换成数组,List的索引器在边界检查之后还有一次内部数组访问,密集循环里差别能测出来。三是避免在循环内创建闭包和 LINQ 查询。
举个具体对比:对 5000 个点做"是否在矩形内"测试,用 LINQ 的points.Count(p => PointInAabb(p, min, max)),我的机器上大约 1.2ms;换成for循环加局部变量缓存min.X、max.X,降到 0.35ms。差距主要来自 LINQ 的委托调用和枚举器分配。这个量级在编辑器里是"能不能跑到 60 帧"的分水岭。
如果业务允许,System.Numerics.Vector<double>或Vector256能对批量距离计算做 4 到 8 路并行。但我的建议是——先做前面三项,SIMD 等到有明确的性能瓶颈再上。SIMD 代码的可读性代价很大,而且不同 CPU 支持的指令集不同,Vector<T>的宽度是运行时决定的,写不好反而更慢。
6. 踩过的坑与排查实录
6.1 浮点比较:那个总是差一点点的问题
最经典的案例:用户把一个矩形拖到和另一个矩形左边缘对齐,界面显示已经对齐了,但代码里rect1.X == rect2.X永远是 false。原因是拖拽过程中坐标经过了"屏幕坐标 → 世界坐标 → 缩放 → 屏幕坐标"的多次变换,累积误差在 1e-7 量级。
解决方案是给所有对齐判定加一个吸附阈值:距离小于snapDistance(物理上取 6 到 8 像素对应的世界单位)就认为是同一个值,并且直接把坐标写死成目标值,而不是保留误差。这一步"写死"很关键,只判定不写死的话,误差会一直留在数据里,导出时又冒出来。
double snap = 8.0 / scale; // scale 是当前缩放倍率 if (Math.Abs(x - targetX) < snap) x = targetX;这里snap要除以缩放倍率,因为屏幕上的 8 像素在不同缩放下对应的世界距离不同。忘了除这一下,放大到 10 倍时吸附范围会变成 80 像素,用户体验极差。
另一个坑是double.NaN的传播性。只要有一个NaN进入坐标,后续所有比较都是 false,图形就"消失"了,而且不报错。我在工具类里加了一个调试用的断言:
[Conditional("DEBUG")] public static void AssertFinite(Vec2 v, string tag) { if (double.IsNaN(v.X) || double.IsNaN(v.Y) || double.IsInfinity(v.X) || double.IsInfinity(v.Y)) throw new InvalidOperationException($"坐标非法: {tag} ({v.X}, {v.Y})"); }只在 Debug 下生效,Release 零开销。这个断言帮我定位过三次线上问题,都是除以零导致的。
6.2 角度死区和边界值的处理
有个项目要判断"设备是否朝正北",我一开始写成Math.Abs(angle - 0) < 1e-6,结果设备在 359.9999 度时被判成"不是正北",因为角度归一到[0, 360)之后是接近 360 的值,和 0 差了 360。这类问题统称为"角度环绕",处理方式有三种:
一是所有角度比较都先做差再归一化:
double diff = NormalizeDeg180(a - b); // 结果在 (-180, 180] if (Math.Abs(diff) < tol) { /* 认为相等 */ }二是用单位向量比较代替角度比较,也就是Dot(d1, d2) > cos(tol),完全绕开角度环绕问题。这个写法在"判断两个方向是否足够接近"时更优雅,也不用担心 tol 的单位。
三是给角度定义一个"死区"概念,只在死区外才更新状态,避免在边界来回抖动。比如设备朝向在 0 度附近时,用 1 度的滞回区间:从正数侧进入要求小于 1 度,从负数侧进入要求大于 359 度,两者不重合,就不会抖。
我一般选择第二种(向量比较),因为它从根本上消除了角度这个维度,代码也短。
6.3 常见问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 角度整体偏 90 度 | Atan2参数顺序写反 | 改成Atan2(y, x) |
| 角度符号与视觉相反 | 屏幕坐标 Y 向下未翻转 | 引入screenYDown参数统一处理 |
| 两条线明明相交却返回 false | 平行阈值写死,短线段判定过严 | 阈值乘以两向量长度 |
| 点在线段端点附近判定失效 | 未做t钳位 | 用ClosestPointOnSegment |
| 旋转矩形判定随角度增大逐渐错位 | 逆旋转矩阵符号错误 | 用 45 度/90 度案例做单测 |
| 图形偶尔整体消失 | 出现NaN并传播 | 加 Debug 断言 + 除零保护 |
| 拖拽时贴边抖动 | 多个碰撞体依次分离 | 位移累加后一次应用 |
| 缩放后吸附范围异常变大 | 像素阈值未除以缩放倍率 | 阈值换算到世界单位 |
| 大量图元时帧率崩溃 | LINQ / 装箱 / 反复开方 | 改循环 + 平方距离 + 数组 |
这张表里前四项我都在实际项目里遇到过,处理方式也都是当时验证有效的。特别是"平行阈值写死"这一条,症状很隐蔽——大部分线段相交正常,只有极短或极长的线段出问题,很容易被当成"偶发 bug"放过去。
6.4 一个容易忽略的初始化顺序问题
图形计算本身没问题,但结果不对,有时候锅在调用顺序上。典型场景:先计算了图元的世界坐标包围盒,然后修改了图元的尺寸,但没重新算包围盒,命中测试用的还是旧数据。这类问题在事件驱动的 UI 里特别常见,因为修改尺寸可能来自另一个事件,执行顺序取决于消息队列。
我的做法是给包围盒加一个"脏标记",所有几何计算前统一走EnsureBounds(),而不是散落在各处的"改完记得更新"。代价是每次多一次布尔判断,收益是彻底消灭这一类顺序依赖。这个模式在图形编辑器里几乎是标配,值得一开始就搭好。
7. 我个人在用的几条习惯
写这类代码久了,会形成一些固定的偏好,分享出来供参考。
所有几何函数都只接受纯数据,不碰 UI 对象。早期我把PointF、RectangleF直接传进计算函数,后来发现换框架时全要重写。现在计算层只认自己的Vec2和double,UI 层负责转换,两边的边界很清楚,测试也好写。
每个函数都在 XML 注释里写清楚坐标系和单位。比如/// <param name="angleRad">弧度,屏幕坐标下顺时针为正</param>。这一行注释的价值远超它的长度,半年后的自己会感谢现在的自己。
宁可多写一个专门函数,也不要加布尔开关。SegmentIntersect(..., bool includeEndpoints)这种设计,调用处永远要翻文档确认哪个值是什么行为。拆成SegmentIntersectOpen和SegmentIntersectClosed两个函数,读代码时一秒就能看懂,代价只有几行重复。
单元测试只写能暴露符号错误的用例。不需要穷举,只需要 30 度、45 度、90 度、-90 度、以及一个对称图形。这套最小集在过去三年里帮我挡住了至少七八次改坏函数的回归。
性能优化从测量开始,不从直觉开始。我见过太多人上来就把清晰的两行代码改成十行位运算"优化",结果因为分支预测失败反而更慢。老实地跑一遍Stopwatch,先确认热点在哪,再动手。
最后提一句:这套东西写熟了之后,遇到新的图形交互需求,基本都是"从工具箱里挑几个函数拼起来"的活。真正花时间的从来不是公式推导,而是把坐标系、精度、边界情况这三件事在项目里一次性说清楚。前面那些看起来啰嗦的约定和注释,省下的是后面无数个"这图怎么又歪了"的下午。