Arcball 旋转机制详解
ArcBall模型变换是一种用户界面技术,它允许用户通过鼠标操作来直观地旋转三维物体。这项技术由Ken Shoemake在1992年提出,并发表在Graphics Interface杂志上。ArcBall的主要思想是将二维鼠标移动映射到三维空间中的旋转,使得用户可以通过拖动鼠标来旋转3D对象,就像他们在虚拟环境中用真实的球体来操纵对象一样。ArcBall的一个关键特性是它可以确保旋转感觉自然,并且与用户的期望相符。例如,如果用户想要绕某个特定的轴旋转对象,他们只需沿着相应的屏幕方向拖动鼠标即可
1. 前置知识
1.1 单位
- 屏幕坐标:像素(px)。
- 3D 坐标:单位球的坐标,无单位(归一化为单位球的半径 = 1)。
- 角度单位:弧度(radians)。
1.2 符号定义
- (x,y):鼠标在屏幕上的二维坐标。
- W,H:窗口的宽度和高度(像素)。
- (x′,y′,z′):鼠标映射到单位球上的三维坐标。
- v1,v2:单位球上的起点和终点向量。
- axis:旋转轴向量。
- θ:旋转角度。
2. 步骤与公式
2.1 步骤 1:鼠标坐标映射到单位球面
公式 1:归一化屏幕坐标
x' = \frac{2x - W}{W}, \quad y' = \frac{H - 2y}{H}
- (x′,y′):归一化后的二维屏幕坐标。
- W,H:窗口的宽和高。
- 注意:H−2y 翻转 Y 轴,确保 Y 轴朝上。
公式 2:计算单位球上的 Z 坐标
单位球公式:
x'^2 + y'^2 + z'^2 = 1
解得:
- 当
x'^2 + y'^2 \leq 1:
z' = \sqrt{1 - x'^2 - y'^2}
- 当
x'^2 + y'^2 > 1(鼠标点在球体外部):
x' = \frac{x'}{\sqrt{x'^2 + y'^2}}, \quad y' = \frac{y'}{\sqrt{x'^2 + y'^2}}, \quad z' = 0
映射结果:
\mathbf{v} = (x', y', z')
2.2 步骤 2:计算旋转轴和旋转角度
公式 3:旋转轴
旋转轴由起点和终点向量的叉积计算:
\mathbf{axis} = \mathbf{v_1} \times \mathbf{v_2}
叉积公式:
\mathbf{axis} = \begin{bmatrix} y_1' z_2' - z_1' y_2' \\ z_1' x_2' - x_1' z_2' \\ x_1' y_2' - y_1' x_2' \end{bmatrix}
归一化旋转轴:
\mathbf{axis} = \frac{\mathbf{axis}}{\|\mathbf{axis}\|}
公式 4:旋转角度
旋转角度由起点和终点向量的点积计算:
\cos(\theta) = \mathbf{v_1} \cdot \mathbf{v_2}
点积公式:
\mathbf{v_1} \cdot \mathbf{v_2} = x_1' x_2' + y_1' y_2' + z_1' z_2'
反余弦函数求角度:
\theta = \arccos(\cos(\theta))
数值稳定性处理:
\cos(\theta) = \min(1, \max(-1, \mathbf{v_1} \cdot \mathbf{v_2}))
2.3 步骤 3:生成旋转矩阵
利用 Rodrigues 旋转公式生成旋转矩阵:
R = I + \sin(\theta) \cdot K + (1 - \cos(\theta)) \cdot K^2
单位矩阵 II:
I = \begin{bmatrix} 1 & 0 & 0 \\ 0 & 1 & 0 \\ 0 & 0 & 1 \end{bmatrix}
- 反对称矩阵 KK(基于旋转轴 (x,y,z)(x, y, z)):
K = \begin{bmatrix} 0 & -z & y \\ z & 0 & -x \\ -y & x & 0 \end{bmatrix}
- 矩阵 K2K^2:
K^2 = \begin{bmatrix} -y^2 - z^2 & xy & xz \\ xy & -x^2 - z^2 & yz \\ xz & yz & -x^2 - y^2 \end{bmatrix}
3. 实现步骤总结
- 将鼠标坐标映射到单位球面,得到起点和终点向量 v1,v2。
- 计算旋转轴 axis 和旋转角度 θ。
- 使用 Rodrigues 公式生成旋转矩阵 R。
- 将旋转矩阵应用到 3D 模型的变换中。
4.java版本实现(可在javafx中使用)
/**
* ArcBall 算法实现,用于处理 3D 空间的旋转。
*
* @author 冰白寒祭
* @date 2025-01-07
*/
public class ArcBall {
private final int width; // 窗口宽度
private final int height; // 窗口高度
/**
* 构造函数,初始化窗口尺寸。
*
* @param width 窗口宽度
* @param height 窗口高度
*/
public ArcBall(int width, int height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("width 和 height 必须为正数.");
}
this.width = width;
this.height = height;
}
/**
* 将屏幕坐标映射到单位球面。
*
* @param x 鼠标的 X 坐标
* @param y 鼠标的 Y 坐标
* @return 映射到单位球的 3D 向量
*/
public double[] mapToSphere(double x, double y) {
// 归一化屏幕坐标
double xNorm = (2.0 * x - width) / width;
double yNorm = (height - 2.0 * y) / height;
// 计算 Z 坐标
double lengthSquared = xNorm * xNorm + yNorm * yNorm;
if (lengthSquared > 1.0) {
double length = Math.sqrt(lengthSquared);
return new double[]{xNorm / length, yNorm / length, 0.0}; // 投影到单位球面
}
return new double[]{xNorm, yNorm, Math.sqrt(1.0 - lengthSquared)};
}
/**
* 计算旋转轴。
*
* @param v1 起点向量
* @param v2 终点向量
* @return 归一化后的旋转轴向量
*/
public double[] computeRotationAxis(double[] v1, double[] v2) {
if (v1.length != 3 || v2.length != 3) {
throw new IllegalArgumentException("向量必须恰好有 3 个元素.");
}
double[] axis = {
v1[1] * v2[2] - v1[2] * v2[1],
v1[2] * v2[0] - v1[0] * v2[2],
v1[0] * v2[1] - v1[1] * v2[0]
};
// 归一化
double length = Math.sqrt(axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]);
if (length > 1e-6) {
for (int i = 0; i < axis.length; i++) {
axis[i] /= length;
}
}
return axis;
}
/**
* 计算旋转角度。
*
* @param v1 起点向量
* @param v2 终点向量
* @return 旋转角度(弧度)
*/
public double computeRotationAngle(double[] v1, double[] v2) {
if (v1.length != 3 || v2.length != 3) {
throw new IllegalArgumentException("向量必须恰好有 3 个元素.");
}
double dotProduct = v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2];
// 防止数值误差
dotProduct = Math.max(-1.0, Math.min(1.0, dotProduct));
return Math.acos(dotProduct);
}
/**
* 生成旋转矩阵。
*
* @param axis 旋转轴向量
* @param angle 旋转角度(弧度)
* @return 旋转矩阵 (3x3)
*/
public double[][] generateRotationMatrix(double[] axis, double angle) {
if (axis.length != 3) {
throw new IllegalArgumentException("轴向量必须恰好有 3 个元素.");
}
double x = axis[0], y = axis[1], z = axis[2];
double sinTheta = Math.sin(angle);
double cosTheta = Math.cos(angle);
double oneMinusCosTheta = 1.0 - cosTheta;
return new double[][]{
{
cosTheta + x * x * oneMinusCosTheta,
x * y * oneMinusCosTheta - z * sinTheta,
x * z * oneMinusCosTheta + y * sinTheta
},
{
y * x * oneMinusCosTheta + z * sinTheta,
cosTheta + y * y * oneMinusCosTheta,
y * z * oneMinusCosTheta - x * sinTheta
},
{
z * x * oneMinusCosTheta - y * sinTheta,
z * y * oneMinusCosTheta + x * sinTheta,
cosTheta + z * z * oneMinusCosTheta
}
};
}
/**
* 从旋转矩阵中提取欧拉角(绕 X、Y、Z 轴的旋转角度)。
*
* 绕 X 和 Z 轴的角度 (xAngle, zAngle): 值域是 [-180°, 180°]
* 绕 Y 轴的角度 (yAngle): 由于旋转矩阵的限制,yAngle 的值域是 [-90°, 90°]。
* @param rotationMatrix 3x3 旋转矩阵
* @return 长度为 3 的数组 {xAngle, yAngle, zAngle},单位为角度
*/
public double[] extractEulerAngles(double[][] rotationMatrix) {
if (rotationMatrix.length != 3 || rotationMatrix[0].length != 3) {
throw new IllegalArgumentException("旋转矩阵必须为 3x3.");
}
double xAngle, yAngle, zAngle;
// 提取绕 Y 轴的旋转角(弧度)
yAngle = Math.asin(-rotationMatrix[2][0]);
if (Math.abs(Math.cos(yAngle)) > 1e-6) {
// 正常情况
xAngle = Math.atan2(rotationMatrix[2][1], rotationMatrix[2][2]);
zAngle = Math.atan2(rotationMatrix[1][0], rotationMatrix[0][0]);
} else {
// 万向节锁
xAngle = 0;
zAngle = Math.atan2(-rotationMatrix[0][1], rotationMatrix[1][1]);
}
// 转换为角度制
return new double[]{
Math.toDegrees(xAngle),
Math.toDegrees(yAngle),
Math.toDegrees(zAngle)
};
}
}