四元数与3D旋转实例!

【本文参与征文活动】

Cocos Creator 3D Quaternion
用几个实用的例子带你理解四元数!

前言

本文不会讲太多四元数公式的推导过程,重点讲讲几个接口的使用和个人理解。

阅读本文可能需要一些前置的知识(但不限于这些知识点):

  • 向量 (内积外积/基本运算/几何意义)
  • 坐标系(左手系/右手系/世界坐标/本地坐标)
  • 矩阵(平移/旋转/缩放/模型矩阵/视图矩阵/投影矩阵)
  • 视点和视线(视点/观察目标/上方向)

https://www.bilibili.com/video/BV1Bt4y1v7R1/ (万向锁可以看这个个人理解)

表示3D旋转一般采用三种方法:

  • 矩阵
  • 欧拉角
  • 四元数

为什么使用四元数表示旋转呢?

  • 平滑插值。 (矩阵基本没有,欧拉角可以做插值,但可能遭遇万向锁的问题)
  • 快速连接和角位移求逆。
  • 能和矩阵快速转换。
  • 仅用四个数。(矩阵9个,欧拉角3个)
  • 难以理解,学会了看起来很牛逼。

当然四元数也有一些缺点:

  • 四元数可能不合法。(一般通过四元数标准化解决这个问题,确保四元数为单位四元数)
  • 对给定的方位的表达方式有两种方法,它们相互为负。(矩阵唯一,欧拉角有无数种)
  • 相对难以使用。

实例

构造四元数

四元数的定义这边就不详细说了,大概知道就是用四个数字去表达旋转。

那么怎么去构造这个四元数呢?我们从API入手去讲解和理解。

旋转轴和旋转角

有了旋转轴和旋转角,就可以表示旋转了,那么四元数也可以通过这个构造出来。

/**
* @zh 根据旋转轴和旋转弧度计算四元数
*/
public static fromAxisAngle<Out extends IQuatLike, VecLike extends IVec3Like> (out: Out, axis: VecLike, rad: number) {
    rad = rad * 0.5; // 为什么要除以2?因为公式推导出来的!
    const s = Math.sin(rad);
    out.x = s * axis.x;
    out.y = s * axis.y;
    out.z = s * axis.z;
    out.w = Math.cos(rad);
    return out;
}

本地坐标轴

根据该物体本地坐标轴也能确定旋转。

/**
* @zh 根据本地坐标轴朝向计算四元数,默认三向量都已归一化且相互垂直
*/
public static fromAxes<Out extends IQuatLike, VecLike extends IVec3Like> (out: Out, xAxis: VecLike, yAxis: VecLike, zAxis: VecLike) {
    Mat3.set(m3_1,
        xAxis.x, xAxis.y, xAxis.z,
        yAxis.x, yAxis.y, yAxis.z,
        zAxis.x, zAxis.y, zAxis.z,
    );
    return Quat.normalize(out, Quat.fromMat3(out, m3_1));
}

视口和上方向

根据视口的前方向和上方向,先计算本地坐标轴的右向量,再算出本地坐标的上向量,最后再构造成四元数。

/**
* @zh 根据视口的前方向和上方向计算四元数
* @param view 视口面向的前方向,必须归一化
* @param up 视口的上方向,必须归一化,默认为 (0, 1, 0)
*/
public static fromViewUp<Out extends IQuatLike, VecLike extends IVec3Like> (out: Out, view: VecLike, up?: Vec3) {
    Mat3.fromViewUp(m3_1, view, up);
    return Quat.normalize(out, Quat.fromMat3(out, m3_1));
}

两向量间的最短路径旋转

也可以用一个四元数表示量向量旋转的最短路径。

/**
* @zh 设置四元数为两向量间的最短路径旋转,默认两向量都已归一化
*/
public static rotationTo<Out extends IQuatLike, VecLike extends IVec3Like> (out: Out, a: VecLike, b: VecLike) {
  // 省略代码实现
}

矩阵/欧拉角

也可以通过其他表示方法转换为四元数。

/**
* @zh 根据三维矩阵信息计算四元数,默认输入矩阵不含有缩放信息
*/
public static fromMat3<Out extends IQuatLike> (out: Out, m: Mat3) {
    // 省略代码实现
}

/**
* @zh 根据欧拉角信息计算四元数,旋转顺序为 YZX
*/
public static fromEuler<Out extends IQuatLike> (out: Out, x: number, y: number, z: number) {
    // 省略代码实现
}

获取四元数相关信息

上面讲了如何去构造,相应的也可以通过四元数获取相关信息,这里不细讲了含义了,直接看看API吧。

/**
* @zh 获取四元数的旋转轴和旋转弧度
* @param outAxis 旋转轴输出
* @param q 源四元数
* @return 旋转弧度
*/
public static getAxisAngle<Out extends IQuatLike, VecLike extends IVec3Like> (outAxis: VecLike, q: Out) {
    //...
}

/**
* @zh 返回定义此四元数的坐标系 X 轴向量
*/
public static toAxisX (out: IVec3Like, q: IQuatLike) {
    //...
}

/**
* @zh 返回定义此四元数的坐标系 Y 轴向量
*/
public static toAxisY (out: IVec3Like, q: IQuatLike) {
    //...
}

/**
* @zh 返回定义此四元数的坐标系 Z 轴向量
*/
public static toAxisZ (out: IVec3Like, q: IQuatLike) {
    //...
}

/**
* @zh 根据四元数计算欧拉角,返回角度 x, y 在 [-180, 180] 区间内, z 默认在 [-90, 90] 区间内,旋转顺序为 YZX
* @param outerZ z 取值范围区间改为 [-180, -90] U [90, 180]
*/
public static toEuler (out: IVec3Like, q: IQuatLike, outerZ?: boolean) {
   //...
}

实际例子

没有实战,单纯讲API就是耍流氓!直接进入实战部分!

角色朝向和平滑插值

已知当前点和下一个点,如何求出角色的朝向四元数?

  • 先算出前方向
  • 根据视口上方向求出四元数
const cur_p = list[index - 1]; // 当前点
const next_p = list[index]; // 最终点
const quat_end = new Quat(); // 最终旋转四元数
const dir = next_p.clone().subtract(cur_p); // 前向量
// 模型正好朝z轴方向
Quat.fromViewUp(quat_end, dir.normalize(), v3(0, 1, 0)); // 根据视口的前方向和上方向计算四元数  
// 最终旋转四元数 / 视口面向的前方向 / 视口的上方向

已知起始四元数和终点四元数,如何平滑旋转?

const tw = tween(this.node_bezier_role); // 使用tween动画
const quat_start = new Quat();
this.node_bezier_role.getRotation(quat_start); // 获取起始四元数
const quat_end = new Quat(); // 最终旋转四元数 假设已经算出
const quat_now = new Quat(); // 用一个中间变量
tw.to(0.2, {}, {
    onUpdate: (target, ratio: number) => {
        // ratio : 0~1
        // 这里使用球面插值,旋转时不会出现变形
        quat_now.set(quat_start).slerp(quat_end, ratio);
        this.node_bezier_role.setRotation(quat_now);
    },
})
tw.start();

将旋转和移动结合起来就能达到下面这个效果。

触摸旋转

关键是求出旋转轴,这边处理的旋转轴在 xoy 这个平面上。

//  private onTouchMove(touch: Touch) {
const delta = touch.getDelta();

// 自传
// 这个物体模型‘锚点’在正中心效果比较好
// 垂直的轴,右手  
//  
//  旋转轴
//  ↑
//  ---> 触摸方向
const axis = v3(-delta.y, delta.x, 0); //旋转轴,根据相似三角形求出
const rad = delta.length() * 1e-2; //旋转角度
const quat_cur = this.node_touch_rotation_role.getRotation(); //当前的四元数
Quat.rotateAround(this.__temp_quat, quat_cur, axis.normalize(), rad); //当面的四元数绕旋转轴旋转
// 旋转后的结果 / 当前的四元数 / 旋转轴 / 旋转四元数
this.node_touch_rotation_role.setRotation(this.__temp_quat);

展示结果如下:

绕轴旋转

已知旋转点、旋转轴、旋转角度,求旋转后的位置和朝向。

朝向计算和触摸旋转类似,这里不详说了。

这边讲讲如何计算旋转后的坐标。

  • 先计算旋转点和当前位置点的向量(起始向量)
  • 计算旋转四元数
  • 计算起始向量旋转后的向量
  • 计算旋转后的坐标点

//  private onTouchMove(touch: Touch) {
const delta = touch.getDelta();
// 绕轴转
// 这里选取轴朝上
const axis2 = Vec3.UP;//旋转轴
const rad2 = 1e-2 * delta.x; //旋转角度
// 计算坐标
const point = this.node_axi.worldPosition; //旋转点
const point_now = this.node_touch_axi_role.worldPosition; // 当前点的位置
// 算出坐标点的旋转四元数
Quat.fromAxisAngle(this.__temp_quat, axis2, rad2);
// 计算旋转点和现有点的向量
Vec3.subtract(this.__temp_v3, point_now, point);
// 计算旋转后的向量
Vec3.transformQuat(this.__temp_v3, this.__temp_v3, this.__temp_quat)
// 计算旋转后的点
Vec3.add(this.__temp_v3, point, this.__temp_v3);
this.node_touch_axi_role.setWorldPosition(this.__temp_v3);

// 计算朝向
// 这么旋转会按原始的朝向一起旋转
const quat_now = this.node_touch_axi_role.worldRotation;
Quat.rotateAround(this.__temp_quat, quat_now, axis2, rad2);
Quat.normalize(this.__temp_quat, this.__temp_quat);
this.node_touch_axi_role.setWorldRotation(this.__temp_quat);

最终效果如下。

小结

可以把四元数当作一个工具,想想旋转可以是用轴角度,本地坐标系,或着视角方向构造出来的,再使用相应的接口去实现我们的各种需求。

以上为白玉无冰使用 Cocos Creator 3D v1.2 实现 "四元数与旋转" 的技术分享。欢迎分享给身边的朋友!

参考



2020 原创精选! shader | 挖洞 | 流体 | 3D | 绳子纹理 | 四叉树 | 数学 样样都有!

原文链接
完整代码

原创文章导航

33赞

存起来 ----------

mark mark

请教个问题。文章的最后的一个例子,。其实是小人在一个平面上绕着中心的小人旋转。而且其实是绕着轴旋转。如果我希望在 360度方向上完全旋转。就是变成绕着一个中心点旋转,而不是轴旋转。应该怎么写代码呢?

两种方法参考一下。

  1. 参考触摸旋转,给这个物体一个父节点,旋转这个父节点,移动模型。相当于锚点的概念。
  2. 参考绕轴旋转。根据触摸方向计算旋转轴。

:3:仅供参考

1赞

太强了,收藏!

这些基础 都非常实用,要存起来

感谢分享!

太强了,收藏!

你好,项目有绕轴旋转的需求,刚刚接触3D很多API不太会用,看了你的教程以为懂了,但是实现出来的旋转效果不是预期的。
论坛发帖可能没怎么被注意,如果方便,能否帮忙看下我是哪里使用的问题导致异常的出现呢?

若有需要,可以上传一个demo。

不明觉厉!!!mark

十分有用,谢谢大佬!

好东西,存下来偷偷享用

:+1:collect!!!

const rad = delta.length() * 1e-2; //旋转角度
请问,这个用到的是哪个公式?

fromAxes 为啥求值都是 (0,0,0,1)啊。 都是无效值。。