在二维计算几何中,求两条线段的交点是最基础也最容易出错的运算之一。由于坐标通常以双精度浮点数存储,直接套用解析几何公式往往会因为舍入误差而得到错误结果,尤其是当两条线段几乎平行时,误差会被急剧放大。理解并处理好这些精度问题,是写出稳定几何算法的关键。

为什么浮点数会让交点计算失真
标准线段交点算法通常基于参数方程。设线段一为 P + t * d1,线段二为 Q + u * d2,其中 P、Q 是起点,d1、d2 是方向向量。联立方程后,分母往往是 cross(d1, d2),也就是两个方向向量的叉积。当两条线段接近平行时,这个叉积的绝对值非常小,浮点除法会把这个微小分母的误差成倍放大到 t 和 u 上,进而导致交点坐标完全偏离真实位置。
另一个容易被忽视的问题是交叉相乘中的大数吃小数。假设坐标值在几百到几千的量级,叉积计算涉及多个乘积相加,若其中一项因浮点舍入损失低位,最终符号判断都可能出错,把本该相交的线段判为不相交,或者反过来。这种错误在碰撞检测和多边形裁剪中会引发肉眼可见的裂缝或重叠。
基础算法与精度陷阱示例
下面是一段常见的直接计算交点的代码,它看起来正确,却隐藏着精度隐患:
#include <cmath>
struct Point {
double x, y;
};
// 叉积
double cross(double ax, double ay, double bx, double by) {
return ax * by - ay * bx;
}
// 返回是否相交,out 为交点
bool segment_intersect(Point P, Point Q, Point R, Point S, Point& out) {
double d1x = R.x - P.x;
double d1y = R.y - P.y;
double d2x = S.x - Q.x;
double d2y = S.y - Q.y;
double denom = cross(d1x, d1y, d2x, d2y);
if (std::fabs(denom) < 1e-12) {
return false; // 平行或共线,简单跳过
}
double dx = Q.x - P.x;
double dy = Q.y - P.y;
double t = cross(dx, dy, d2x, d2y) / denom;
double u = cross(dx, dy, d1x, d1y) / denom;
if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
out.x = P.x + t * d1x;
out.y = P.y + t * d1y;
return true;
}
return false;
}
这段代码用了一个固定的绝对阈值 1e-12 来判断平行。如果坐标本身很大,比如地图经纬度放大到百万级别,1e-12 相对误差可以忽略;但如果坐标是归一化到零到一的小数,这个阈值又可能太大,把明显相交的线段误判为平行。此外,t 和 u 的范围判断没有容差,边界上的交点常因误差被拒之门外。
在真实项目中,这种写法会导致多边形布尔运算时出现微小缺口。比如两个矩形共享一条边,理论上交点应落在端点,但浮点偏差让 t 算出 1.0000000000000002,直接被 t <= 1 拒掉,缝合逻辑随之失败。因此我们必须引入相对容差和更严谨的归类处理。
引入容差与相对误差比较
改进的核心是用相对误差替代绝对阈值。对于分母 denom,我们可以结合输入向量的量级来动态估算可接受的最小叉积。一种简单做法是取方向向量长度乘积乘以一个机器精度相关的系数,例如 1e-10。这样无论坐标是大是小,判断标准都随尺度自适应。
对于 t 和 u 的范围检查,也不要严格卡 0 和 1,而是允许一个小的 epsilon 溢出,比如判断 t > -eps && t < 1 + eps,最后再把交点坐标 clamp 回线段端点附近。下面给出一个更稳健的版本:
#include <cmath>
struct Point {
double x, y;
};
double cross(double ax, double ay, double bx, double by) {
return ax * by - ay * bx;
}
bool near_zero(double v, double scale) {
return std::fabs(v) <= scale * 1e-10;
}
bool segment_intersect_safe(Point P, Point Q, Point R, Point S, Point& out) {
double d1x = R.x - P.x;
double d1y = R.y - P.y;
double d2x = S.x - Q.x;
double d2y = S.y - Q.y;
double denom = cross(d1x, d1y, d2x, d2y);
double len_scale = std::sqrt(d1x*d1x + d1y*d1y) * std::sqrt(d2x*d2x + d2y*d2y);
if (near_zero(denom, len_scale)) {
return false; // 视为平行,实际项目可另处理共线
}
double dx = Q.x - P.x;
double dy = Q.y - P.y;
double t = cross(dx, dy, d2x, d2y) / denom;
double u = cross(dx, dy, d1x, d1y) / denom;
double eps = 1e-9;
if (t > -eps && t < 1 + eps && u > -eps && u < 1 + eps) {
t = std::fmax(0.0, std::fmin(1.0, t));
out.x = P.x + t * d1x;
out.y = P.y + t * d1y;
return true;
}
return false;
}
这个版本先根据向量长度动态计算 near_zero 的尺度,避免绝对阈值带来的尺度错配。随后对 t 和 u 做带容差的范围判断,并把最终 t 裁剪到合法区间,从而保证交点不会因浮点溢出而跑出线段之外。这种方式在游戏物理和 CAD 系统中比裸公式稳定得多。
要注意的是,eps 的选择需要结合业务场景。渲染管线里像素级误差可接受稍大容差,而科学计算网格划分可能要求更紧。一般建议把 eps 设成比坐标量级乘机器精度大一个数量级,既不过度宽松也不过分严苛。
共线情形的特殊处理
当 denom 极小且线段实际共线时,上述代码直接返回不相交,但共线重叠段本应有无数交点。若业务需要,应单独检测共线:先验证 cross(Q-P, R-P) 是否近零,再比较投影区间是否重叠。这部分逻辑虽然繁琐,却是完整几何库不可省略的。
处理共线时可以只返回重叠区间的一个代表点,比如起点投影最大的那个端点,避免语义歧义。同时所有比较同样使用相对容差,防止浮点抖动让本该重叠的线段被判定分离。经过这层补充,线段交点模块才能在各种退化输入下保持行为一致。
总结与实践建议
计算线段交点时,浮点数精度问题不是靠换用更高精度类型就能彻底解决的,核心在于算法层面的容差设计与退化情况分类。先用动态相对阈值判断平行或共线,再用带 eps 的参数范围检查替代硬边界,最后对结果做裁剪,这三步能覆盖绝大多数工程场景。
如果项目对正确性要求极高,也可以引入整数坐标或定点数来彻底规避浮点误差,但会带来性能和表达范围的成本。对于常见的图形与交互应用,上文给出的容差方案已经足够可靠,且易于维护和调试。