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. 实现步骤总结

  1. 将鼠标坐标映射到单位球面,得到起点和终点向量 v1,v2。
  2. 计算旋转轴 axis 和旋转角度 θ。
  3. 使用 Rodrigues 公式生成旋转矩阵 R。
  4. 将旋转矩阵应用到 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)
        };
    }
}

人生不作安期生,醉入东海骑长鲸