☰
Unity四元数完全解析:从轴角原理到Quaternion实战,告别旋转难题
2026/10/13 5:11:54 网站建设 项目流程

在Unity里待久了你会发现,真正让新手崩溃的往往不是复杂的逻辑,而是一个看起来很简单的问题——让物体转向某个方向。明明写好了transform.rotation = ...,结果物体转到一半开始乱翻;用欧拉角累加旋转,转着转着突然出现奇怪的角度跳变。这些问题背后几乎都是同一个主角:旋转的表示方式。而Unity官方推荐、内部真正在用的,就是四元数(Quaternion)。

这篇笔记是C#学习笔记系列的第四篇,也是旋转专题的上半部分。我先不急着甩API,而是把四元数到底是什么、为什么Unity非要推荐它、以及它和欧拉角的恩怨纠葛讲清楚。这些内容我不知道该怎么用一句话概括,所以说点实在的:理解了这些之后,你会发现自己看Quaternion.Euler、Quaternion.LookRotation这类接口时,不再是“背单词”,而是在看一个四维数据的底层逻辑。适合刚接触Unity的C#学习者,也适合写了几个月脚本、天天跟eulerAngles搏斗但一直没空补基础的人。

1. 为什么“旋转”值得单独写一篇笔记

旋转在游戏开发里属于那种“看着简单,实际全是坑”的领域。理由很直白:游戏里的旋转不只是让模型转个圈,它经常承担着“方向表达”的职责。

1.1 游戏里的旋转问题,远不止“把物体转一下”

举几个最常见的例子:角色要面朝敌人,子弹要飞向准星方向,摄像机要平滑跟随目标,无人机追踪目标时要边转弯边调整姿态。这些需求表面上都是“旋转”,但实际上它们需要处理的是连续的方向变化、局部坐标系与世界坐标系的换算、以及在两个姿态之间做平滑过渡。这些问题里,旋转不仅仅是数字上的变化,而是整个游戏逻辑的一部分。

我自己带过的一个模拟项目里,有个新手同学想做一个“炮台追踪目标”的功能。他的第一版代码是这样写的:拿到目标的坐标后,算一个方向向量,然后用这个向量的三个分量去拼欧拉角。结果炮台确实能大致转向目标,但目标的俯仰角度一大,炮管就开始抽风,画面惨不忍睹。原因特别典型:他绕过了Unity内置的方向转姿态工具,强行用欧拉角去表示一个“三维方向”,结果踩中了欧拉角的奇异性。

这个例子的价值在于:旋转不是一套可以随便拼凑的数字,它是一个有严格数学结构的量。你用什么方式表示它,决定了你能对它做什么操作、会不会出问题。

1.2 学习路线建议:先理解“表示方式”,再学API

我在日常交流里发现一个现象:很多人写Unity脚本时,Quaternion.LookRotation、Quaternion.Slerp用得飞起,但你要问他“四元数为什么是四个数”,他大概率答不上来。这其实很危险,因为不知道底层含义,遇到bug时就没法判断是逻辑错了还是旋转表示方式选错了。

所以我建议的学习路线是:先花点时间弄清楚“旋转有哪些表示方式、各自有什么特点”,再去记API。本篇笔记就是沿着这个思路写的,先解决“四元数是什么”这个认知问题,下一部分再深入讲具体接口的细节和组合姿势。

2. 四元数的真面目:它只是一个“带方向的旋转量”

很多人一听“四元数”三个字就头大,以为是什么高深的数学分支。说实话,如果你是从推公式的角度去学,确实头大;但如果只是想在Unity里用好它,理解方式可以非常朴素。

2.1 从“轴-角”理解,先忘掉数学恐惧

你给我一个旋转,我换一种描述方式:绕哪个轴转了多少度。比如“绕世界Y轴转90度”“绕自身的右方向轴转45度”,这种说法叫轴-角表示。一个轴需要三个数(比如(x, y, z)方向),加上一个角度,一共四个信息。四元数就是把这四个信息打包成四个数字。

所以四元数的第一个直觉是:一根旋转轴加一个旋转角度。Unity里Quaternion的构造函数签名是Quaternion(x, y, z, w),很多新手第一次看到四个数就懵了:transform的坐标只有xyz,怎么旋转多一个w?答案就在这里——xyz是旋转轴的方向,w负责编码旋转角度。

不过注意,这个“编码”不是直接把角度存进去那么简单。Unity中四元数的实际关系是:

  • w = cos(角度 / 2)
  • x = 旋转轴的x分量 * sin(角度 / 2)
  • y = 旋转轴的y分量 * sin(角度 / 2)
  • z = 旋转轴的z分量 * sin(角度 / 2)

角度要除以2这件事,被很多人忽略,但它能解释很多奇怪现象。比如绕Y轴旋转180度,在四元数里角度分量是90度,cos90=0,所以得到的是(0, 1, 0, 0)而不是(0, 0, 0, 1)。如果你自己对着数值调四元数分量,不把半角算清楚,一定会调出莫名其妙的结果。

2.2 四个分量不是“四个坐标”,而是“轴+角度的打包”

我再换个角度加深印象。四元数的xyz分量不是让你直接“改物体的朝向”的,它描述的是旋转轴的方向。比如Quaternion.Euler(0, 90, 0)对应的四元数值是(0, 0.7071, 0, 0.7071),意思是:旋转轴是Y轴方向(0, 1, 0),而这个轴方向的每个分量都乘以了sin(45°)≈0.7071,所以y分量是0.7071;w是cos(45°)≈0.7071。

下面这张表列出几个常见旋转的四元数值,理解之后你对这个数据结构会更有手感:

旋转描述四元数(x, y, z, w)数值含义
无旋转(identity)(0, 0, 0, 1)旋转角度为0,所以w=cos0=1
绕X轴旋转90度(0.7071, 0, 0, 0.7071)旋转轴是X轴,角度90/2=45度
绕Y轴旋转180度(0, 1, 0, 0)旋转轴是Y轴,角度180/2=90度,cos90=0
绕世界Z轴旋转45度(0, 0, 0.3827, 0.9239)45/2=22.5度,cos22.5≈0.9239

这里注意,表中的“绕X轴”指的是旋转轴的方向在世界坐标里是X轴方向,不是说你直接改物体的x坐标。搞混了的话,后面学习旋转叠加时会绕晕。

2.3 同一个旋转,四元数是不唯一的(q和-q表示同一个姿态)

四元数还有一个很有意思的特性:把一个四元数的四个分量全部取负,它表示的旋转和原来完全一样。用公式理解就很直观:cos(θ/2)和cos(θ/2 + π)只差一个正负号?其实不是,这里说的取负是(x, y, z, w)变成(-x, -y, -z, -w),本质是把旋转轴反向、同时把角度补偿一下,最终旋转效果不变。在插值场景里,这个性质会导致“走最短路径”还是“绕远路”的问题,这也是为什么Slerp这类接口内部往往要做特殊处理。

你不需要把这个性质背下来,但要知道它存在。因为当你自己去比较两个四元数是否相等、或者实现插值逻辑时,如果不处理“正负等价”,很容易写出有bug的工具函数。

3. 为什么Unity官方不推荐用欧拉角?

你可能会问:既然四元数这么绕,Transform面板上明明显示的是欧拉角(X、Y、Z三个角度),Unity为什么还要在代码里强烈推荐四元数?

3.1 万向锁不是“锁死了”,是少了一个自由度

欧拉角最大的问题之一叫万向锁。用数学说法是:当某个轴向转到正负90度时,原本独立的两个旋转轴会变成同一个轴,导致旋转少了一个自由度。

我用一个更直观的例子:想象手持稳定器,它有三个旋转环,分别对应pitch(俯仰)、yaw(偏航)、roll(横滚)。如果中间那个环转到了90度,外环和内环的旋转轴就会重合。这时你想通过手柄让云台做某个方向的转动,会发现不管怎么操作,镜头都只会在一个平面里转圈,另一个方向的自由度凭空消失了。

在游戏里这个现象非常致命。典型场景是FPS摄像机的俯仰:当玩家把视角抬到正上方附近时,如果继续转方向,画面会突然出现诡异的翻滚。用欧拉角直接驱动旋转的摄像机,在高俯仰角下会开始不受控地滚转。

四元数没有万向锁问题,因为它本质上是“轴+角”,不管当前姿态是什么,都能稳定地表达下一步的旋转,不存在“两个轴重合导致缺失自由度”的情况。

3.2 插值噩梦:从欧拉角拼出来的中间姿态非常不可控

游戏里经常需要让一个物体平滑旋转到目标姿态,这时要用到“插值”。如果用欧拉角的三个角度做线性插值,会产生一个很反直觉的问题:中间姿态不是均匀的旋转路径。

举个例子,物体从姿态A旋转到姿态B,A的欧拉角是(0, 0, 0),B的欧拉角是(90, 0, 0)——只绕X轴转90度,线性插值看起来没什么问题。但如果B是(45, 45, 45),直接对三个角度做插值,中间某个时刻会出现绕X轴和绕Z轴同时转、但绕Y轴又很慢的情况,整个旋转路径是扭曲的。尤其当两个姿态相差很大时,欧拉角插值的路径会“绕远”,视觉上就是我们常说的“旋转飘了”。

四元数的Slerp(球面线性插值)则不同,它是直接在“球面”上找两个姿态之间的最短弧线,转起来是均匀的。这个例子不夸张地说,我见过的很多“镜头旋转很生硬”的求助帖,最后的病根都是用了欧拉角插值。

3.3 欧拉角在编辑器的存在意义

既然欧拉角问题这么多,Unity为什么还能在Transform面板里看到它?因为欧拉角对人类友好。你要在编辑器里把一个物体“绕Y轴转90度”,直接输一个数字比去算四元数直观得多。Unity的做法是:编辑器里显示欧拉角,方便人工调整;脚本运行和物理模拟内部则用四元数,保证数值稳定。

所以一个重要的认知是:你在Inspector里看到的Rotation,是Unity把内部四元数“翻译”成欧拉角之后显示出来的,并不是说物体的旋转真的以“三个角度”的形式存储。这也解释了为什么有时候你给脚本里transform.rotation赋了值,再去Inspector看Rotation,数值可能跟你预想的不完全一致——因为同一个旋转可以对应多组欧拉角,Unity只是选了其中一组显示。

4. Unity中Quaternion的实操基础

清楚了四元数的底层含义之后,再来写代码就会顺畅很多。这一节我挑几个最基础、最常用的创建和操作方式,全部给出代码和注释,方便直接抄。

4.1 从欧拉角创建四元数,和从轴角创建四元数

最常用的创建方式是从欧拉角生成四元数,对应的接口是Quaternion.Euler:

// 绕世界坐标的Y轴旋转90度 Quaternion q1 = Quaternion.Euler(0f, 90f, 0f); // 使用Vector3作为参数 Quaternion q2 = Quaternion.Euler(new Vector3(30f, 45f, 60f));

需要注意,Quaternion.Euler的参数单位是“度”,不是弧度。Unity里大量接口用度,但数学计算里又经常需要弧度,所以转换要留意,比如Mathf.Deg2Rad和Mathf.Rad2Deg。

从“任意轴+角度”创建旋转用Quaternion.AngleAxis:

// 绕世界Y轴旋转90度,和上面q1等价 Quaternion q3 = Quaternion.AngleAxis(90f, Vector3.up); // 绕自身右方向轴旋转45度(注意这个轴的方向会受物体当前旋转影响) Quaternion q4 = Quaternion.AngleAxis(45f, transform.right);

AngleAxis非常直观,尤其适合做“刀刀绕某个轴转”的动画,比如门轴旋转、螺旋桨转动。它的另一个优势是可以方便地传入任意向量作为旋转轴,这是Euler做不到的。

无旋转状态用Quaternion.identity,相当于欧拉角(0,0,0)。这个值在初始化、归位时特别常用:

Transform target = GameObject.Find("Target").transform; target.rotation = Quaternion.identity; // 回到初始旋转

4.2 旋转叠加顺序:乘法顺序里的本地与世界空间陷阱

四元数可以相乘,表示“把两个旋转叠加起来”。这个叠加和我们日常乘法很不一样,要特别注意顺序,因为四元数乘法不满足交换律:a * b不等于b * a。

Unity中的约定是:a * b表示先应用b的旋转,再应用a的旋转。这一点记忆方式可以简单记为:右边的旋转先生效。

初学者最容易踩坑的地方是“本地旋转”和“世界旋转”的区别。看下面两行代码:

transform.rotation *= Quaternion.Euler(0f, 90f, 0f); // 绕自身的Y轴转90度 transform.rotation = Quaternion.Euler(0f, 90f, 0f) * transform.rotation; // 绕世界Y轴转90度

第一行先把当前旋转拿出来,再乘以新旋转,相当于在当前姿态的本地坐标系里继续绕Y轴转。第二行则是先把世界Y轴的旋转作用上去,再乘当前姿态,结果是绕世界坐标系的Y轴转。

这种差异在实战中非常常见。比如一个角色可能希望“按自己面对方向左右转向”,那就用本地旋转;而想让物体水平偏航,不受当前俯仰影响,那就要用世界旋转。

我见过一个很典型的错误:想让摄像机绕着世界Y轴水平旋转,结果写成了transform.rotation *= Quaternion.Euler(0f, speed * Time.deltaTime, 0f),在俯仰角为0时看起来正常,但一旦摄像机抬头或低头,画面就开始歪。原因就是它一直在绕摄像机自己的Y轴转,而不是世界Y轴。

4.3 方向计算:LookRotation、FromToRotation

很多需求是“让物体朝向某个目标点”,这时推荐用Quaternion.LookRotation。它的作用是:给出一个forward方向,得到一个让物体的Z轴指向这个方向的四元数。

Vector3 targetPos = enemy.transform.position; Vector3 direction = targetPos - transform.position; direction.y = 0f; // 如果只想水平朝向,可以去掉垂直分量 transform.rotation = Quaternion.LookRotation(direction);

这里有个细节:LookRotation需要一个非零向量。如果你的方向向量长度接近0,Unity会直接报错“Look rotation viewing vector is zero”。所以实际使用时要先判断方向长度,或者对向量做归一化处理。

还有一个类似接口叫Quaternion.FromToRotation,它解决的是另一个问题:已知两个方向向量,想求一个旋转,把第一个方向转到第二个方向。这个接口适合做“把当前物体的某个轴向对齐到某个目标方向”这样的操作:

// 让物体的右方向对齐到目标方向 Vector3 targetDir = (target.position - transform.position).normalized; transform.rotation = Quaternion.FromToRotation(Vector3.right, targetDir);

FromToRotation和LookRotation的区别在于,前者是“任意向量到任意向量”,后者是“指定物体的forward方向到目标方向”。实际做导弹追踪、炮塔瞄准时,LookRotation更常用;做“某个轴贴向墙面”之类的蒙皮或机关动画时,FromToRotation更灵活。

4.4 旋转的插值:Slerp、Lerp、RotateTowards

最后简单提插值接口,这部分下篇会重点展开,这里先建立基本概念。

Quaternion.Slerp是最常用的“平滑旋转”方案,它做的是球面插值,旋转速度均匀,不会有欧拉角插值那种路径扭曲:

// 每帧朝目标旋转,插值系数越大转得越快 transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * 2f);

Quaternion.Lerp是线性插值,计算速度快一些,但严格来说它做的不是“球面”路径,当两个旋转差异较大时,中间姿态会有一点漂移,不够均匀。差别不大时(比如角度小于30度),Lerp和Slerp几乎看不出区别,性能敏感的地方可以选Lerp。

Quaternion.RotateTowards则限制最大旋转步长,适合做“匀速转头”的效果,不会像Slerp那样越接近目标越慢:

float maxDegreesPerStep = 30f * Time.deltaTime; transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, maxDegreesPerStep);

插值相关的坑比较多,比如用Time.deltaTime做系数导致的速度不稳定、两个四元数互为“负等价”导致插值路径绕远等,这些我打算在下篇里单独讲,这里先不展开。

5. 常见误区、问题排查与调试技巧

学四元数的过程里,如果说数学概念是门槛,那实际项目里的坑就是“连环雷”。下面是我自己踩过、以及给别人看代码时经常见到的几类问题,做了个速查表,再逐个展开。

现象常见原因正确做法
物体朝奇怪方向翻转乘法顺序搞反,本地和世界空间混用明确需求后选择rotation *=或rotation = q * rotation
直接改四元数xyz后姿态混乱四元数分量不是坐标,直接改会破坏旋转结构用Euler、AngleAxis等接口生成四元数
设置eulerAngles后回读数值不一致同一旋转对应多组欧拉角不要依赖欧拉角的回读值做逻辑判断
长时间累加旋转后姿态漂移四元数模长偏离、浮点误差堆积对中间结果做.normalized,用角度变量管理累加
镜头转到正上方后乱跳欧拉角驱动导致的万向锁改用四元数乘法叠加旋转,少用欧拉角加减

5.1 误区一:直接修改四元数的分量

很多新手看到transform.rotation有x、y、z、w四个数,觉得和transform.position差不多,于是写:

transform.rotation.x = 0.5f; // 错误示例

这段代码在Unity中根本不能这样写,因为rotation返回的是四元数结构体,不是可写属性;就算能写,你也不知道这个数是什么意思。四元数的四个分量不是“四维坐标”,而是“轴+角”的编码结果。你想绕X轴转90度,正确的做法是Quaternion.Euler(90f, 0f, 0f),或者Quaternion.AngleAxis(90f, Vector3.right),而不是去猜xyz该填多少。

5.2 误区二:对eulerAngles做加减法

还有一种常见操作是把Transform面板上的三个角度当成普通数来加减,错误示范:

transform.eulerAngles += new Vector3(0f, 90f, 0f); // 危险写法

在俯仰角比较小的时候,这段代码有时候看起来还行,好像确实绕Y轴转了90度。但一旦物体的pitch已经不是0,这种写法就不再是“绕世界Y轴旋转”,而是“在欧拉角空间里加一个数”,结果会完全出乎意料。

正确的做法是:

transform.rotation *= Quaternion.Euler(0f, 90f, 0f); // 本地Y轴转90度 // 或者 transform.rotation = Quaternion.Euler(0f, 90f, 0f) * transform.rotation; // 世界Y轴转90度

如果你想做“每帧累加角速度”的效果,也建议维护一个独立的角度变量,累加完后再用Quaternion.Euler或AngleAxis一次性生成四元数,而不是反复修改eulerAngles。

5.3 误区三:把旋转和“目标角度”做等值判断

有时候你想判断“物体是否已经转到指定角度”,新手常见的做法是:

if (transform.eulerAngles == targetAngle) { ... }

这种做法非常脆弱。一方面,eulerAngles回读的值受内部归一化影响,大多是浮点数,精确匹配几乎不可能;另一方面,目标角度可能有多种等价的四元数表示,靠欧拉角比较根本对不上。

更稳的做法是计算两个旋转的夹角:

// 判断当前旋转和目标旋转是否足够接近 float angle = Quaternion.Angle(transform.rotation, targetRotation); if (angle < 1f) { // 到位 }

Quaternion.Angle返回的是两个旋转之间的最小夹角(单位度),这是做“转到目标后触发事件”这类逻辑的主力接口,比比较欧拉角可靠得多。

5.4 排查技巧:用可视化辅助调试旋转

旋转问题光看数值很难判断,我强烈建议在场景里画线辅助排查。Unity提供了Debug.DrawRay或OnDrawGizmos,可以快速画出物体的前方向、右方向等关键轴,然后观察它们跟目标方向是否一致:

private void OnDrawGizmos() { // 画出物体的前方向(红色)和右方向(绿色) Gizmos.color = Color.red; Gizmos.DrawRay(transform.position, transform.forward * 2f); Gizmos.color = Color.green; Gizmos.DrawRay(transform.position, transform.right * 2f); }

调试“转向没生效”时,我还习惯在Update里打印当前朝向和期望方向的夹角:

float facingError = Vector3.Angle(transform.forward, targetDirection); Debug.Log($"朝向偏差: {facingError}");

这样能快速定位问题:如果夹角一直不变,说明转向逻辑根本没执行;如果夹角来回抖动,可能是插值系数或乘法顺序有问题。另外,把四元数打印出来观察也很有用:

Debug.Log($"rotation: {transform.rotation}"); Debug.Log($"eulerAngles: {transform.eulerAngles}");

打印rotation时会显示四个分量,配合前面的表就能判断当前旋转是不是符合预期。

6. 实操中的一些心得,以及下篇会讲什么

纸上谈兵到这里已经够多了,最后分享一点我练四元数时的实操心得。

我自己学四元数的方式是:在空白场景里摆两个Cube做实验。一个Cube是“观察对象”,另一个Cube是“目标点”。然后写脚本让第一个Cube用几种不同方式转向目标,边转边观察它的朝向轴和目标方向。这个实验看起来简单,但它能帮你一次性理解乘法顺序、世界空间和本地空间的差别,以及LookRotation内部的“forward对齐”逻辑。

做过几个小实验之后,你会自然记住一个核心结论:Unity中表示旋转,优先用四元数;需要给人看的角度,再转成欧拉角。代码逻辑里尽量避免欧拉角的加减乘除,而是用四元数的乘法、LookRotation、RotateTowards这些“正经接口”来操作。

这个旋转专题的下篇,我会重点展开几个应用场景:Slerp做摄像机平滑跟随时的参数细节,LookRotation在追踪目标时的环形修正,刚体旋转(Rigidbody.rotation)与Transform旋转的联动,以及动画层里四元数混合的常用做法。这些内容比较吃实操,到时候会配合完整代码和运行表现一起讲,这次先到这里。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询