在图形学、游戏物理引擎和 CAD 软件中,判断两个椭圆是否相交并求出交点,是一个看似简单实则充满数值陷阱的问题。很多人会想到用包围盒或采样多边形近似,但椭圆经过旋转后,轴对齐包围盒会大幅放大碰撞区域,产生大量误报;而多边形近似则牺牲了接触点的精度。精确方法的核心思路是把椭圆当作二次曲线处理。本文将给出一种基于仿射变换的解析求交算法:先把其中一个椭圆变换为单位圆,把双椭圆求交转化为圆与椭圆的求交,再求解四次方程得到全部交点,最后结合隐函数梯度计算法向量,完成碰撞响应。该方法没有迭代误差累积,适合需要高精度接触判定的场景。

一、椭圆的一般方程与仿射变换化简
任意旋转椭圆在平面直角坐标系中都可以表示为一般二次曲线方程:Ax² + Bxy + Cy² + Dx + Ey + F = 0。当判别式 B² - 4AC < 0 且满足一定退化条件时,该曲线表示一个椭圆。与圆不同,椭圆的半轴方向不一定与坐标轴对齐,因此不能仅靠两个中心之间的距离和两个半径来判定相交。例如一个长轴沿对角线方向拉长的椭圆,它的轴对齐包围盒会把大量空白区域包进来,导致误判。
为了简化问题,可以利用仿射变换的性质:仿射变换保持直线、二次曲线的类型不变,也就是说椭圆在仿射变换后仍然是椭圆。于是我们可以选择一个合适的仿射变换,把第一个椭圆映射为单位圆 x² + y² = 1。具体做法是:先把椭圆中心平移至原点,再旋转使其长轴与 x 轴重合,最后沿两个轴方向分别缩放 1/rx 和 1/ry。经过这个变换,第一个椭圆变成了单位圆,而第二个椭圆在变换后的坐标系中依然是椭圆,只是中心、半轴和旋转角都发生了改变。这样双椭圆求交就转化为圆与椭圆求交,后者的方程求解要简单得多。
下面给出一个椭圆类的 Java 实现,包括存储中心、半轴和旋转角,以及构造局部到单位圆变换的矩阵。变换矩阵采用 6 元素仿射数组表示,避免使用外部几何库。
import java.awt.geom.Point2D;
public class Ellipse {
public final double cx, cy;
public final double rx, ry; // 半轴长度
public final double theta; // 旋转角(弧度)
public Ellipse(double cx, double cy, double rx, double ry, double theta) {
this.cx = cx;
this.cy = cy;
this.rx = rx;
this.ry = ry;
this.theta = theta;
}
// 返回将椭圆映射为单位圆的仿射矩阵
// 矩阵形式为 [m00 m10 m01 m11 m02 m12]
// 对应 x' = m00*x + m01*y + m02
// y' = m10*x + m11*y + m12
public double[] toUnitCircleTransform() {
double cos = Math.cos(theta);
double sin = Math.sin(theta);
return new double[] {
cos / rx, sin / rx,
-sin / ry, cos / ry,
(-cx * cos - cy * sin) / rx,
(cx * sin - cy * cos) / ry
};
}
// 将单位圆上的点变换回椭圆的世界坐标
public Point2D.Double fromUnitCircle(Point2D.Double p) {
double cos = Math.cos(theta);
double sin = Math.sin(theta);
double x = p.x * rx;
double y = p.y * ry;
return new Point2D.Double(cx + x * cos - y * sin, cy + x * sin + y * cos);
}
}
二、圆与椭圆的交点求解
在第一个椭圆的局部坐标系中,单位圆方程为 x² + y² = 1,而第二个椭圆仍然可以用一般二次曲线方程表示。为了得到第二个椭圆在局部坐标系下的新系数,可以先把第二个椭圆上的点通过第一个椭圆的逆变换映射到局部坐标,再代入椭圆方程整理得到新的 A、B、C、D、E、F。这一步是纯代数运算,可以通过矩阵乘法完成。由于我们使用了仿射变换,新方程仍然满足椭圆判别式条件,因此不会引入双曲线或抛物线的分支。
求圆与椭圆交点的常用方法是利用单位圆的有理参数化:x = (1 - u²) / (1 + u²),y = 2u / (1 + u²)。其中参数 u 对应圆上除 (-1, 0) 点以外的所有点。把这个参数化代入椭圆的一般方程,两边乘以 (1 + u²)²,就得到一个关于 u 的四次多项式。四次多项式的实根对应圆与椭圆的交点参数,每个实根代回参数化公式即可得到局部坐标下的交点。没有实根则说明圆与椭圆要么分离,要么一个完全包含另一个,需要进一步判断。
四次方程求根可以使用数值库,比如 Apache Commons Math 提供的 LaguerreSolver。下面代码展示了如何构造四次多项式并过滤出实数根,同时排除由数值误差引入的虚部很小的伪实根。注意根的去重和排序,以确保后续碰撞响应计算稳定。
import org.apache.commons.math3.analysis.polynomials.PolynomialFunction;
import org.apache.commons.math3.analysis.solvers.LaguerreSolver;
import org.apache.commons.math3.complex.Complex;
public class QuarticSolver {
public static double[] solveQuartic(double a, double b, double c, double d, double e) {
// 系数数组从常数项到最高次项
double[] coeffs = new double[] { e, d, c, b, a };
PolynomialFunction poly = new PolynomialFunction(coeffs);
LaguerreSolver solver = new LaguerreSolver();
Complex[] roots = solver.solveAllComplex(poly.coefficients, 0.0);
double[] realRoots = new double[4];
int count = 0;
for (Complex z : roots) {
if (Math.abs(z.getImaginary()) < 1e-8) {
double r = z.getReal();
boolean dup = false;
for (int i = 0; i < count; i++) {
if (Math.abs(realRoots[i] - r) < 1e-7) {
dup = true;
break;
}
}
if (!dup && count < 4) {
realRoots[count++] = r;
}
}
}
double[] result = new double[count];
System.arraycopy(realRoots, 0, result, 0, count);
return result;
}
}
还需要单独检查参数化遗漏的点 (-1, 0)。只需将该点代入椭圆方程,如果满足则作为一个交点加入。对于每个有效参数 u,通过有理参数化公式计算局部坐标 (x, y),再使用第一个椭圆的 fromUnitCircle 方法转换回世界坐标,就得到了两个椭圆在世界坐标系中的精确交点。
三、交点判定与碰撞响应
得到交点列表后,相交判定变得直接:如果至少存在一个实数交点,且该点同时位于两个椭圆的边界上,则两个椭圆相交。如果没有交点,则需要区分完全分离和包含两种情况。判断包含可以检查第二个椭圆的中心在第一个椭圆局部坐标系中是否满足单位圆内部条件,或者检查椭圆方程在中心点的符号。对于物理碰撞响应,我们更关心的是接触点、法向量和穿透深度。
椭圆隐式方程 F(x, y) = 0 的梯度 ∇F = (∂F/∂x, ∂F/∂y) 指向曲线在该点的外法线方向。对于单位圆,梯度就是 (2x, 2y),归一化后就是点坐标本身。在局部坐标系中求得法向量后,需要把它变换回世界坐标系。这里不能直接用点坐标的变换矩阵,而应该使用该变换的逆转置矩阵作用于法向量,这样才能保持法向量与切平面的垂直关系。对于我们的仿射变换,逆转置矩阵很容易计算:由于先旋转后缩放,法向量变换对应先按 1/rx 和 1/ry 的倒数缩放,再旋转回原角度。
穿透深度通常取最小平移向量,即能把两个椭圆分开的最小位移量。一种计算方式是在每个交点上分别求出两个椭圆的法向量,然后选择模长最小的向量作为分离方向。如果两个椭圆有多个交点,取平均或最小模长都能得到稳定结果。下面代码演示了法向量计算和最小平移向量的提取过程。
import java.awt.geom.Point2D;
import java.util.ArrayList;
public class CollisionResponse {
// 计算椭圆在给定局部坐标点处的单位外法向量
public static Point2D.Double normalAtUnitCircle(Point2D.Double p) {
double len = Math.hypot(p.x, p.y);
if (len < 1e-12) return new Point2D.Double(0, 0);
return new Point2D.Double(p.x / len, p.y / len);
}
// 将局部法向量映射回世界坐标(使用逆转置矩阵)
public static Point2D.Double toWorldNormal(Point2D.Double localNormal, Ellipse ellipse) {
double cos = Math.cos(ellipse.theta);
double sin = Math.sin(ellipse.theta);
// 局部单位圆法向量先按 1/rx,1/ry 缩放,再旋转 theta
double nx = localNormal.x / ellipse.rx;
double ny = localNormal.y / ellipse.ry;
return new Point2D.Double(nx * cos - ny * sin, nx * sin + ny * cos);
}
// 根据交点列表计算最小平移向量
public static Point2D.Double computeMTV(Point2D.Double[] contacts, Ellipse e1, Ellipse e2) {
Point2D.Double mtv = new Point2D.Double(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY);
for (Point2D.Double p : contacts) {
Point2D.Double local = e1.toUnitCircleTransform();
// 此处简化:实际需要将接触点变换到 e1 局部坐标
Point2D.Double localP = new Point2D.Double(
(p.x - e1.cx) * Math.cos(-e1.theta) - (p.y - e1.cy) * Math.sin(-e1.theta),
(p.x - e1.cx) * Math.sin(-e1.theta) + (p.y - e1.cy) * Math.cos(-e1.theta)
);
Point2D.Double n1 = normalAtUnitCircle(localP);
Point2D.Double worldN = toWorldNormal(n1, e1);
// 简单选择法向量作为分离方向,模长近似
mtv = new Point2D.Double(worldN.x, worldN.y);
}
return mtv;
}
}
这段代码为了简洁省略了坐标变换的完整步骤,实际工程中应当把局部坐标变换封装成方法,避免反复计算三角函数。碰撞响应时,可以将 mtv 作用于其中一个椭圆的位置,使其沿分离方向移动以解除穿透。
四、边界情况与性能优化
精确检测算法在数值上需要注意退化情形。当两个椭圆同轴或一个椭圆退化为圆时,四次方程可能出现重根或系数为零的情况。例如圆与圆相切时,四次方程会有两个重根,实数根过滤时需要允许一定的容差。两个椭圆同心且半轴成比例时,交点可能不存在但包含关系确定,此时应优先判断中心距离与半轴关系,避免盲目求解四次方程。另外,当椭圆非常扁平时,仿射变换中的缩放可能放大数值误差,建议在变换前对坐标进行归一化。
性能方面,四次方程求根的计算量很小,在普通桌面 CPU 上单次检测耗时仅为微秒级,完全可以用于实时物理模拟。对于一些对性能极端敏感的场景,可以先使用旋转矩形包围盒或分离轴定理做预筛选,排除明显不相交的椭圆对。只有在预筛选无法确定时才调用精确求交算法。这样结合粗筛和精判,既保证了速度又获得了解析精度。
本文给出的方法避免了多边形近似带来的误差累积,也摆脱了迭代法可能不收敛的问题。它适合作为几何内核中的基础碰撞检测原语,也可以扩展用于椭圆与线段、椭圆与矩形的相交测试。只要保持变换思路一致,将目标图形转换为单位圆后,很多二次曲线求交问题都可以统一处理。
Java椭圆相交检测椭圆碰撞响应解析几何修改时间:2026-09-27 03:40:33