Better

Ethan的博客,欢迎访问交流

关于镜头旋转那些事儿?

三维世界中镜头控制器与旋转动画那点事!

缘由

当前场景中的元素均是基于 XY 平面绘制,最初没有打开旋转功能,所以看不出异样。当我们把旋转功能打开时,于是变成了这样。

查到方案通过如下设置,可以达到正常状态。

camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);

Camera 默认的 up 向量为 (0, 1, 0),也就是 Y 轴,从左右旋转现象大概也能看出是绕 Y 轴旋转了。那么理想状态应该是绕 Z 旋转,这样简单理解下,挺合理的。

但当我们实现 ViewHelper 时,通过四元素 Quaternion 实现旋转时,不太理想的事情发生了。

  • 当转到 Z 或 -Z 时,最后一帧会突然发生旋转
  • 从 X 到 Y 时,Z 轴也会有坐标变化

前置知识

简单了解下相关知识

  • 球坐标 Spherical
  • 欧拉角 Euler
  • 四元数 Quaternion

球坐标 Spherical

函数签名:Spherical(radius: Float, phi: Float, theta: Float)

  • phi 表示为与 y 轴的角度,从正 y 开始,取值范围 [0, PI]
  • theta 表示绕 y 轴旋转角,从正 z 开始,取值范围 [-PI, PI]

向量与 y 轴构成一个平面,与 y 轴角度,叫做极角 phi,绕 y 轴旋转的角度,也可理解成向量在 xz 平面的投影与 z 轴的夹角,叫做方位角 theta。

欧拉角 Euler

表达旋转最简单的一种方式,形式上是一个三维向量,其值分别代表物体绕坐标系三个轴的旋转角度,人类易于使用是它的主要优势。

函数签名:Euler(x: Float, y: Float, z: Float, order: String),对应各个轴旋转量,以及旋转顺序,默认 XYZ。

使用 Yaw、Pitch、Roll 描述旋转,旋转在本地坐标系(动态欧拉角)下进行,最开始与世界坐标系对齐,之后均相对自身(如同机长开飞机,每次新的指令都是相对当前状态进行描述)

  • 俯仰 Pitch
  • 偏航 Yaw
  • 桶滚 Roll

注意:欧拉角无法直接作用于向量,可以直接作用于向量的只有四元数与矩阵。

优缺点

  • 数据少、直观
  • 运算复杂:逆运算、插值计算
  • 万向锁:一旦选择 ±90° 作为 pitch 角,就会导致第一次旋转和第三次旋转等价,整个旋转表示系统被限制在只能绕竖直轴旋转,丢失了一个表示维度

大部分情况下,这一套顺序都能够对三个不同的轴分别进行三次选择。然而,在某些特殊情况下,它其中的某两个变换可能变换的是同一个轴(这里指的不是物体自身的轴,而是世界的轴),这样就导致我们丧失了一个轴的自由度,从而导致 Gimbal Lock 的产生。

一般情况下,我们选择其中固定的一个顺序来编码旋转或者朝向,而这正是导致 Gimbal Lock 的原因,但这是代码编写需要的。

四元数 Quaternion

表示物体绕任意一根轴旋转的方法。

函数签名 Quaternion(x: Float, y: Float, z: Float, w: Float)

  • 通过 x/y/z/w 四个分量表示,具体含义见下
  • 通过方法 setFromAxisAngle(axis, angle) 设置旋转轴(归一化向量)和旋转角度得到四元素

理解四元数需要将实数的概念扩充到复数

  • 复数可以分为两类数:实数和虚数
  • 实数包括有理数和无理数
  • 有理数主要包括整数、分数、有限小数、无限循环小数
  • 无理数包括开方开不尽的数、无限不循环小数
  • 形如 a+bi 的数,其中 a、b 属于实数且 b 不等于 0,其中 i 是虚数单位,i 的平方等于 -1

虚数几何意义

  • z=a+bi 对应复平面 (a, b) 点
  • z*w 左边的数作为一个操作平面的函数,去变换右边的数。几何意义:向量的伸缩旋转
  • 旋转数 q=cosθ+sinθi,可用于旋转 2D 复数平面的点

根据四元数和复数的相似性,有没有可能设计一个旋转 3D 空间点的四元数 q=[cosθ, sinθv] 呢

这里不讨论四元数的推导,首先你需要了解的是旋转四元数的一般形式:q=[cos(θ/2), sin(θ/2)v]。因此我们可以根据四元数的 x/y/z/w 得到该四元数表示的旋转含义。

function decompose(q) {
  const { x, y, z, w } = q;
  const radians = Math.acos(w);
  const sinV = Math.sin(radians);
  const vx = x / sinV;
  const vy = y / sinV;
  const vz = z / sinV;
  console.log(`vector is (${vx}, ${vy}, ${vz})`);
  console.log(`radians is ${radians * 2}`);
  console.log(`degree is ${180 / Math.PI * radians * 2}`)
}

一个旋转可以用一个四元数表示,两次连续旋转可以理解为两次旋转对应的四元数对象进行乘法运算,四元数乘积一般式。 $$ q_aq_b = [s_as_b - \vec a \cdot \vec b, s_a \vec b + s_b \vec a+ \vec a \times \vec b ] $$

四元数旋转计算公式为 p'=qpq^-1,其中 q^-1 表示四元数的逆,当 q 为单位四元数时,q^-1=q*q* 为共轭四元数,即四元数向量部分取反。

常见四元数形式

  • 实四元数:虚部向量为零向量的四元数
  • 纯四元数:实数部分为 0 的四元数,即 s=0
  • 单位四元数:s=0、v 为单位向量的四元数
  • 共轭四元数:四元数虚向量取反,常用 q* 表示,qq*=[s^2+v^2, 0]
  • 四元数范数:类似于复数绝对值,常用 |q|=sqrt(s^2+v^2) 表示,因此 qq*=|q|^2
  • 四元数规范化: q/|q|
  • 四元数的逆:用四元数的共轭四元数除以四元数的范数平方,q^-1=q*/|q|^2,对于单位四元数,范数是 1,此时写成 q^-1=q*
  • 四元数点积:和向量点积相似,只需要将各个对应的系数相乘后相加,可以利用四元数的点积计算四元数之间的角度差 $${cosθ}={q1 \cdot q2 \over |q1||q2|}$$

四元数插值

  • slerp:球面线性插值(Spherical Linear Interpolation),在两个四元数间平滑插值,把一个点平滑的插值得到另一个朝向
  • squad:slerp 的扩展版本,用于处理一系列朝向定义得到的一条路径的插值

转换

在 three.js 封装了彼此转换的 api

  • Matrix4.makeRotationFromQuaternion(q)
  • Quaternion.setFromEuler(Euler)
  • Euler.setFromQuaternion(quaternion)

up 向量

up 向量是什么?通过 position、target 和 up 向量用于确定当前坐标系(注意不是世界坐标系)。

  • z = new Vector3().subVectors(eye, target)
  • x = new Vector3().crossVectors(up, z)
  • y = new Vector3().crossVectors(z, x)

那个第一个问题就来了,我们场景中相机位置是 (0, 0, 1500),up 设置为 (0, 0, 1) 之后,由于 z 和 up 向量平行,因此没办法确定一个平面,那么 X 应该无法确定才对,那为什么现在可以工作呢。

因此 lookAt 函数做了什么呢?看下 Object3D 中 lookAt 源码

function lookAt(x, y, z) {
    // ...code
    if (this.isCamera || this.isLight) {
        _m1.lookAt(_position, _target, this.up);
    } else {
        _m1.lookAt(_target, _position, this.up);
    }
    this.quaternion.setFromRotationMatrix(_m1);
    // ...code
}

该函数主要修改是物体的旋转量,其中 Camera 和 Light 的处理方式和普通物体不同。内部调用 Matrix4.lookAt 函数,核心代码如下

function lookAt(eye, target, up) {
    const te = this.elements;
    _z.subVectors(eye, target);
    if(_z.lengthSq() === 0) {
        // eye and target are in the same position
        _z.z = 1;
    }
    _z.normalize();
    _x.crossVectors(up, _z);
    if(_x.lengthSq() === 1) {
        // up and z are parallel
        if(Math,abs(up.z) === 1) {
            _z.x += 0.0001;
        } else {
            _z.z += 0.0001;
        }
        _z.normalize();
        _x.crossVectors(up, _z);
    }
    _x.normalize();
    _y.crossVectors( _z, _x );

    te[ 0 ] = _x.x; te[ 4 ] = _y.x; te[ 8 ] = _z.x;
    te[ 1 ] = _x.y; te[ 5 ] = _y.y; te[ 9 ] = _z.y;
    te[ 2 ] = _x.z; te[ 6 ] = _y.z; te[ 10 ] = _z.z;
    return this;
}

从源码中可以看出,lookAt 本质上就是执行了上面的确定坐标系的步骤,但内部对平行的情况进行了处理,当平行时,人为进行一点小小的偏差,从而让流程可以继续。如果通过右手法则进行判定,这之后坐标系应该是 X 朝上,Y 朝右,Z 朝外,但实际上却不是这样的,见下图。(备注:不要启用 OrbitControls 去看,OrbitControls 会有些特殊的处理。)

那么出现这个现象的原因是什么呢?

const eye = new THREE.Vector3(0, 0, 1500);
const target = new THREE.Vector3(0, 0, 0);
const up = new THREE.Vector3(0, 0, 1);
const matrix = new THREE.Matrix4().lookAt(eye, target, up);
// {_x: 0.000035355338926744854, _y: 0.000035355338926744854, _z: 0.7071067803026639, _w: 0.707106780302664}
const quat = new THREE.Quaternion().setFromRotationMatrix(matrix);
// {_x: 6.77626361191572e-21, _y: 0.00009999999966666665, _z: 1.5707963267948963, _order: "XYZ"}
const euler = new THREE.Euler().setFromQuaternion(quat);

发现结论是相机绕 Z 轴旋转 90 度。要知道相机的 matrix 是由相机的 quaternion、position、scale 确定的,由 matrix 也可以直接解出 quaternion、position、scale。因此 lookAt + up 只是确定了相机自身的朝向。这里需要知道的是,相机的运动和物体的运动是相反的,也就是说相机旋转 90 度看物体,等同于相机不旋转,物体选择 -90 度。因此这时候的坐标轴应该旋转 camera.quaternion 的逆运算。简单测试一下

//  {_x: -0.00009999999966666665, _y: -6.776263578034403e-21, _z: -1.5707963267948963, _order: "XYZ"}
const euler2 = new THREE.Euler().setFromQuaternion(quat.clone().invert());

这样就解释通了为什么世界坐标会变成这样。但如果应用了 OrbitControls 后,坐标系是另一种情况。

OrbitControls

应用了 OrbitControls 后,坐标系神奇恢复到了默认值。这是因为 OrbitControls 会更新相机的位置,源码中 update 函数做的就是这件事,具体如下(删减了这里不需要关心的代码)

function update() {
    const offset = new Vector3();
    // so camera.up is the orbit axis,将 up 轴旋转到世界坐标系 y 轴
    const quat = new Quaternion().setFromUnitVectors(object.up, new Vector3( 0, 1, 0 ) );
    const quatInverse = quat.clone().invert();
    const spherical = new Spherical()
    const position = scope.object.position;
    offset.copy(position).sub(scope.target);
    // rotate offset to "y-axis-is-up" space,将视线向量转变成y轴作为相机up轴的空间之内
    offset.applyQuaternion(quat);
    spherical.setFromVector3(offset);
    // it's important here。控制球坐标系极角上下限,避免变换到极点
    spherical.makeSafe();
    // ...code
    offset.setFromSpherical(spherical);
    // rotate offset back to "camera-up-vector-is-up" space,将视线向量更新到原本相机up轴朝向
    offset.applyQuaternion(quatInverse);
    position.copy(scope.target).add(offset);
    console.log(position);
    scope.object.lookAt(scope.target);
}

使用固定参数,做下简单测试如下

const object = new THREE.Object3D()
object.isCamera = true;
object.position.set(0, 0, 1500);
object.up.set(0, 0, 1)
object.lookAt(0, 0, 0);
const target = new THREE.Vector3(0, 0, 0);
const offset = new THREE.Vector3();
const quat = new THREE.Quaternion().setFromUnitVectors(object.up, new THREE.Vector3( 0, 1, 0 ));
const quatInverse = quat.clone().invert();
const spherical = new THREE.Spherical()
offset.copy(object.position).sub(target);
offset.applyQuaternion(quat);
spherical.setFromVector3(offset);
spherical.makeSafe();
// ...code
offset.setFromSpherical(spherical);
offset.applyQuaternion(quatInverse);
object.position.copy(target).add(offset);
// {x: 0, y: -0.0015000000000782165, z: 1499.9999999992492}
console.log(object.position);
// rotation {_x: 0.0000010000000000518108, _y: 0, _z: -0, _order: "XYZ"}
object.lookAt(target);

经测试,正是由于 makeSafe 的操作,导致 OrbitControls 把 (0, 0, 1500) 丢进去,经过一堆看是无效操作,出来变成了一个 (0, -0.0015000000000782165, 1499.9999999992492),从而导致最终相机无需发生旋转即可,这样就解释了为什么最后会突然旋转。

OrbitControls 工作原理

  • 通过 panOffset 记录平移量,修改 target 和 position 位置实现平移
  • 通过 sphericalDelta 记录旋转量,修改 position 实现旋转
    • 左右旋转通过修改 theta 角
    • 上下旋转通过修改 phi 角
  • 放大缩小处理方式有差别
    • PerspectiveCamera 通过修改 target 和 position 距离
    • OrthographicCamera 通过修改 zoom 属性

扩展:Controls 区别

MapControls、TrackballControls、OrbitControls 区别

  • OrbitControls:轨道控制器,围绕目标进行轨迹运动
  • TrackballControls:轨迹球控制器,与 OrbitControls 区别在于,不能恒定保持摄像机的 up 向量,因为绕过北极和南极也不会翻转,非常的灵活
  • MapControls:其实就是 OrbitControls,仅修改了一些地图上习惯的默认参数,比如左键平移,右键旋转,同时 screenSpacePanning 设置为 false

那么 OrbitControls.screenSpacePanning 属性的作用是什么呢。该属性仅在 panUp 时参与计算,逻辑如下

if(scope.screenSpacePanning === true) {
  v.setFromMatrixColumn(objectMatrix, 1);
} else {
  v.setFromMatrixColumn(objectMatrix, 0);
  v.crossVectors(scope.object.up, v);
}
v.multiplyScalar(distance);

当设置为 true 时,直接从相机矩阵 matrix 取 y 向量,直接作用在相机的 y 上,从而实现屏幕空间的平移,设置为 false 时,从矩阵 matrix 中取 x 向量,使用 up 叉乘 y 得到平移方向,从而实现世界空间平移。

TrackballControls 在 rotateCamera 时会实时改变 up 向量,从而当相机绕过极值时,不会突然发生翻转。

const axis = new Vector3(),
    quaternion = new Quaternion(),
    eyeDirection = new Vector3(),
    objectUpDirection = new Vector3(),
    objectSidewaysDirection = new Vector3(),
    moveDirection = new Vector3();
function rotateCamera() {
    moveDirection.set(__moveCurr.x - _movePrev.x, _moveCurr.y - _movePrev.y, 0);
    let angle = moveDirection.length();
    if (angle) {
        _eye.copy(scope.object.position).sub(scope.target);
        eyeDirection.copy(scope.object.up).normalize();
        objectUpDirection.copy(scope.object.up).normalize();
        objectSidewaysDirection.crossVectors(objectUpDirection, eyeDirection).normalize();

        objectUpDirection.setLength(_moveCurr.y - _movePrev.y);
        objectSidewaysDirection.setLength(_moveCurr.x - _movePrev.x);

        moveDirection.copy(objectUpDirection.add(objectSidewaysDirection));

        axis.crossVectors(moveDirection, _eye).normalize();

        angle *= scope.rotateSpeed;
        quaternion.setFromAxisAngle(axis, angle);

        _eye.applyQuaternion(quaternion);
        scope.object.up.applyQuaternion(quaternion);

        _lastAxis.copy(axis);
        _lastAngle = angle;
    }
    _movePrev.copy(_moveCurr);
}

当前困惑

为什么不修改 UP 向量,也就是使用默认朝 y 就不存在该问题呢?其实也是存在该问题的,当相机越过 y 方向时,应该也会发生翻转,至于 ViewHelper 为什么可以正常工作,我猜测还是个相机运动路径相关,插值的路径并没有越过两极。

官方 ViewHelper 例子中,应用四元数时,使用的是单位向量 (0, 0, 1),StackOverflow 查阅到是因为物体默认朝向是 (0, 0, 1),这样理解其实合理。

但为什么当相机在 Y 时,想要切换到 X,不是直接绕 Z 轴旋转一下,而是翻转一下,使得 Y 朝上呢?还不是很理解。

避免翻转可能的解决办法:手动定义旋转路径,避开极点。比如 A->B 问题转换为 A->C->B 的问题。

资料



留言