1. 从“瞬移感”说起:为什么2D转向值得单独拎出来讲
做2D游戏的人几乎都遇到过这个场景:角色追着鼠标跑,或者按方向键移动,结果一转向就像被人从背后猛推了一把,瞬间从朝左变成朝右,视觉上非常生硬。尤其是像素风或者手绘风格的角色,这种“瞬移式转向”会直接破坏游戏的手感。我最早做俯视角射击小游戏的时候就踩过这个坑,当时用transform.right = moveDirection一行代码搞定,测试的时候自己觉得没问题,结果给朋友试玩,第一句话就是“这角色怎么跟抽风一样”。
这个问题的本质是:朝向的插值不能直接对角度做线性插值。你可能会想,那我把角度从0度慢慢加到90度不就行了?听起来合理,但角度是个循环量,359度和1度只差2度,可如果你直接对数值插值,它会绕一大圈走357度。更麻烦的是,当角色朝向跨越0度/360度边界时,会出现反向旋转的诡异现象。
所以正确做法是:把朝向转换成旋转矩阵或四元数,在矩阵空间里做插值,再转回角度。这篇内容就是围绕这个核心思路展开的,从旋转矩阵的基础推导,到Unity里2D角色平滑转向的完整实现,再到实际项目中会遇到的各种边界情况。适合已经会写C#、用过Unity基本API、但对旋转数学不太熟悉的开发者。看完你至少能拿到一套可以直接抄的代码,并且知道每一行为什么这么写。
2. 旋转矩阵在2D里的本质:不只是“转个角度”
2.1 2D旋转矩阵到底在做什么
先把这个东西说清楚。2D旋转矩阵长这样:
R(θ) = [ cosθ -sinθ ] [ sinθ cosθ ]它的作用是把一个二维向量绕原点旋转θ角度。你可以把它理解成一个“变换器”:丢进去一个方向向量,出来一个旋转后的方向向量。比如角色当前朝右,方向向量是(1, 0),旋转90度后变成(0, 1),也就是朝上。
在Unity的2D场景里,所有物体默认都在XY平面上,Z轴是朝向屏幕外的。角色的朝向本质上就是它的右方向向量(transform.right)或者上方向向量(transform.up),取决于你的美术资源是怎么画的。大多数2D角色素材是“面朝右”绘制的,所以用transform.right来表示朝向最自然。
那为什么不直接用transform.rotation = Quaternion.Euler(0, 0, angle)呢?可以,但问题在于插值。如果你每帧直接设置角度,角色就是瞬间转向;如果你用Mathf.Lerp对角度插值,就会遇到前面说的角度环绕问题。而旋转矩阵(或者等价的四元数)在插值时不会出现这种问题,因为它们在数学上是连续的。
2.2 为什么不用角度插值:一个具体的翻车案例
我拿一个实际数字来说明。假设角色当前朝向角度是350度(接近360度,也就是几乎朝右但偏上一点),目标角度是10度(朝右偏下一点)。从视觉上看,角色只需要顺时针转20度就够了。但如果你用Mathf.Lerp(350, 10, t),它会从350一路降到10,也就是逆时针转340度,角色会像陀螺一样转一大圈。
有人会说,那我用Mathf.LerpAngle不就行了?确实,Unity提供了Mathf.LerpAngle来处理角度环绕,它会自动选择最短路径。但LerpAngle有个问题:它内部还是基于角度做插值,当角度差接近180度时,转向方向会变得不确定,可能出现抖动。而且在需要同时控制旋转速度和方向的情况下,LerpAngle的“最短路径”逻辑有时候并不是你想要的——比如你希望角色始终顺时针转,它就做不到了。
旋转矩阵(或四元数)的插值则没有这些问题。Quaternion.Slerp和Quaternion.RotateTowards都是在四元数空间里做球面插值,天然处理了所有环绕情况,而且转向方向是确定的。
2.3 旋转矩阵与四元数的关系:Unity里用哪个
在Unity的C# API里,你几乎不会直接操作旋转矩阵,因为Quaternion已经封装好了所有你需要的东西。但理解旋转矩阵有助于你明白底层发生了什么。四元数可以看作旋转矩阵的一种更紧凑、更适合插值的表示形式。一个单位四元数(x, y, z, w)对应一个旋转矩阵,两者可以互相转换。
在2D场景里,我们只关心绕Z轴的旋转,所以四元数实际上只有两个有效分量:z和w。x和y始终为0。这意味着2D旋转的插值本质上是一维球面上的插值,非常简单。
Unity里做2D平滑转向,核心API就三个:
Quaternion.Euler(0, 0, angle):从角度创建四元数Quaternion.Slerp(from, to, t):球面插值,t在0到1之间Quaternion.RotateTowards(from, to, maxDegreesDelta):以固定角速度旋转,适合需要控制转速的场景
我个人的选择习惯是:如果转向速度需要平滑变化(比如有加速减速),用Slerp配合自己控制的t值;如果转向速度是恒定的,用RotateTowards更直接。
3. 完整实现:从零搭建一个平滑转向的2D角色
3.1 场景准备与基础代码框架
先假设你已经有一个Unity 2D项目,场景里有一个Sprite作为角色。给角色挂一个C#脚本,名字随便,我一般叫SmoothRotation2D。核心逻辑是:每帧读取输入方向,计算目标角度,然后用四元数插值平滑过渡。
using UnityEngine; public class SmoothRotation2D : MonoBehaviour { [SerializeField] private float rotationSpeed = 360f; // 度/秒 [SerializeField] private bool useSlerp = false; [SerializeField] private float slerpFactor = 10f; private Quaternion targetRotation; void Start() { targetRotation = transform.rotation; } void Update() { Vector2 input = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); if (input.sqrMagnitude < 0.01f) return; float targetAngle = Mathf.Atan2(input.y, input.x) * Mathf.Rad2Deg; targetRotation = Quaternion.Euler(0, 0, targetAngle); if (useSlerp) { float t = 1f - Mathf.Exp(-slerpFactor * Time.deltaTime); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, t); } else { transform.rotation = Quaternion.RotateTowards(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); } } }这段代码里有两个关键点需要解释。
第一,Mathf.Atan2(input.y, input.x)计算的是输入向量与X轴正方向的夹角,单位是弧度,乘以Mathf.Rad2Deg转成角度。这里用Atan2而不是Atan,是因为Atan2能正确处理所有四个象限,而Atan只能返回-90到90度之间的值。
第二,Slerp的t值我用了1f - Mathf.Exp(-slerpFactor * Time.deltaTime)而不是直接乘Time.deltaTime。这是一个经验技巧:直接用slerpFactor * Time.deltaTime的话,插值速度会受帧率影响,帧率越高转向越快。用指数形式可以保证在不同帧率下转向手感一致。这个公式的推导思路是:每帧保留的比例是Exp(-k * dt),那么插值比例就是1 - Exp(-k * dt),当dt趋近于0时,它约等于k * dt,但帧率变化时表现更稳定。
3.2 参数选择:转速到底设多少才舒服
rotationSpeed这个参数没有标准答案,但有一些经验范围可以参考。
对于俯视角射击游戏,角色转向通常要快,因为玩家需要快速瞄准。我一般设在720到1080度/秒之间,也就是半秒到三分之一秒转一整圈。如果是平台跳跃游戏,角色转向不需要那么快,360到540度/秒比较合适。如果是策略类或者解谜类,角色转向可以更慢,180到270度/秒就够了。
用Slerp的时候,slerpFactor的取值逻辑不同。它不是一个速度值,而是一个“收敛速率”。slerpFactor = 10意味着大约0.1秒后完成约63%的转向,0.3秒后完成约95%。我一般设在8到15之间。设得太低(比如3),转向会显得拖沓;设得太高(比如30),又接近瞬间转向,失去了平滑的意义。
注意:如果你用
Slerp并且slerpFactor设得很大,在低帧率下可能会出现“过冲”然后回弹的现象。这是因为每帧插值比例太大,导致角色转过了目标角度。解决办法是给slerpFactor设一个上限,或者改用RotateTowards。
3.3 处理“无输入”状态:角色该保持朝向还是归位
上面的代码里有一行if (input.sqrMagnitude < 0.01f) return;,意思是如果没有输入,就不更新朝向。这适用于大多数情况:玩家松开按键,角色保持当前朝向。
但有些游戏需要角色在停止移动后自动转向某个默认方向,比如朝右或者朝下。这时候你可以在无输入时设置一个默认的targetRotation,然后继续插值。比如:
if (input.sqrMagnitude < 0.01f) { targetRotation = Quaternion.Euler(0, 0, 0); // 默认朝右 } else { float targetAngle = Mathf.Atan2(input.y, input.x) * Mathf.Rad2Deg; targetRotation = Quaternion.Euler(0, 0, targetAngle); } // 插值逻辑放在外面,无论有没有输入都执行这样角色在停止移动后会平滑地转回默认方向。具体用哪种,取决于你的游戏设计。我做过一个双摇杆射击游戏,左摇杆移动、右摇杆瞄准,那时候朝向完全由右摇杆控制,左摇杆只负责位移,两者是独立的。这种设计下,移动停止时朝向不应该变,因为瞄准方向还在。
3.4 与移动逻辑的配合:先转再走还是边走边转
平滑转向和移动逻辑的配合方式会直接影响手感。常见的有三种模式:
模式一:独立转向。转向和移动互不影响,角色可以朝左移动但面朝右(比如横版射击里的“倒退走”)。这种模式下,转向逻辑只由瞄准输入控制,移动逻辑只由移动输入控制。
模式二:转向驱动移动。角色始终朝当前朝向移动,输入方向只决定目标朝向。这种模式下,角色会先转向,然后沿着朝向走。适合坦克式操作或者某些俯视角游戏。
模式三:移动驱动转向。角色沿着输入方向移动,同时平滑转向到移动方向。这是最常见的俯视角角色控制方式,也是我上面代码采用的模式。
模式三有一个细节:如果转向速度很慢,而移动速度很快,角色会出现“横着走”的视觉效果。比如角色朝右,玩家突然按上,角色会先向上移动,同时慢慢转向。这在某些游戏里是合理的(比如滑冰),但在大多数动作游戏里会显得别扭。解决办法是让转向速度足够快,或者让移动速度在转向完成前降低。我一般会在转向角度差大于90度时,把移动速度乘以一个系数(比如0.5),等转向完成后再恢复。
4. 进阶话题:旋转矩阵推导与常见误区
4.1 轴角旋转矩阵是怎么推导出来的
虽然Unity里用四元数就够了,但理解旋转矩阵的推导能帮你排查一些奇怪的问题。2D旋转矩阵的推导其实很直观:假设有一个单位向量(cosα, sinα),你想把它旋转θ角度,得到的新向量应该是(cos(α+θ), sin(α+θ))。用三角函数的和角公式展开:
cos(α+θ) = cosα·cosθ - sinα·sinθ sin(α+θ) = sinα·cosθ + cosα·sinθ写成矩阵形式就是:
[ cosθ -sinθ ] [ cosα ] [ cos(α+θ) ] [ sinθ cosθ ] [ sinα ] = [ sin(α+θ) ]这就是2D旋转矩阵的由来。它本质上就是和角公式的矩阵表示。3D旋转矩阵的推导类似,但复杂得多,因为3D旋转有三个自由度,需要指定旋转轴。轴角旋转矩阵(Rodrigues公式)就是用来处理绕任意轴旋转的情况。
在2D里,旋转轴永远是Z轴,所以问题简化了很多。这也是为什么2D转向比3D转向好处理:你只需要关心一个角度值,而3D需要处理欧拉角的万向锁问题。
4.2 常见误区:直接对transform.right赋值
我见过很多新手(包括当年的自己)会这样写:
transform.right = moveDirection;这行代码确实能让角色朝向移动方向,但它是瞬间完成的,没有任何平滑。而且当moveDirection为零向量时,transform.right会变成(0, 0, 0),导致角色的旋转矩阵退化,出现各种诡异现象,比如角色消失或者缩放变成0。
正确的做法是永远不要直接给transform.right或transform.up赋值,而是通过transform.rotation来设置。如果你确实需要根据方向向量计算旋转,用Quaternion.LookRotation或者Quaternion.FromToRotation,但这两个在2D里都不太直观,还是用Atan2算角度再转四元数最清晰。
4.3 性能考量:Slerp和RotateTowards的开销
有人可能会担心每帧调用Quaternion.Slerp的性能问题。实测下来,在移动端设备上,几千个物体同时做Slerp插值才会有明显开销。对于单个玩家角色来说,这点计算量完全可以忽略。Quaternion.Slerp内部涉及三角函数和归一化,比简单的角度插值确实要重一些,但现代CPU处理这种量级的运算毫无压力。
如果你真的需要极致性能(比如在Update里处理上万个实体),可以考虑用查表法或者近似算法。但对于99%的2D游戏来说,直接用Unity内置的API就是最优解。过早优化是万恶之源,这句话在游戏开发里尤其适用。
5. 实战排查:转向不平滑的几种典型原因
5.1 转向抖动:帧率波动与插值参数
转向抖动是最常见的问题。表现是角色在转向过程中出现轻微的来回摆动,或者到达目标角度后还在微微颤动。原因通常有三个:
原因一:插值参数过大。如果你用Slerp且slerpFactor设得很大,每帧插值比例接近1,角色会在目标角度附近来回过冲。解决办法是降低slerpFactor,或者改用RotateTowards。
原因二:目标角度每帧都在变。如果输入方向有噪声(比如手柄摇杆的微小抖动),目标角度会不断变化,导致角色跟着抖动。解决办法是给输入加一个死区(dead zone),或者对目标角度做平滑处理。
原因三:在FixedUpdate和Update里同时修改旋转。如果你在FixedUpdate里做物理相关的旋转,又在Update里做视觉旋转,两者会打架。解决办法是统一在一个地方处理旋转,通常是在Update里。
5.2 转向方向错误:角度环绕与符号问题
有时候角色会朝反方向转,或者转了大半圈才到位。这通常是角度计算的问题。检查以下几点:
Mathf.Atan2的参数顺序是(y, x),不是(x, y)。写反了会导致角度偏移90度。- 如果你的角色素材是“面朝上”绘制的,那么朝向角度需要额外加90度或减90度的偏移。
- 在Unity的2D坐标系里,逆时针旋转是正方向。如果你希望顺时针为正,需要对角度取负。
我遇到过一次诡异的情况:角色在目标角度是180度时转向正常,但在-180度时就会绕远路。原因是Atan2返回的范围是-180到180度,而Quaternion.Euler接受任意角度。虽然四元数插值本身能处理环绕,但如果你的目标角度在-180和180之间跳变,插值路径会变得不确定。解决办法是统一角度范围,或者在计算目标角度后做一次归一化。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 转向瞬间完成 | 没有使用插值,直接赋值 | 检查是否用了Slerp或RotateTowards | 改用四元数插值 |
| 转向抖动 | 插值参数过大或输入噪声 | 降低slerpFactor,加输入死区 | 调整参数或过滤输入 |
| 转向方向相反 | 角度符号或Atan2参数错误 | 打印目标角度和当前角度 | 检查Atan2参数顺序 |
| 转大半圈才到位 | 角度环绕问题 | 打印角度差,看是否超过180度 | 用四元数插值或归一化角度 |
| 角色消失或缩放异常 | 直接给transform.right赋值零向量 | 检查是否有零向量赋值 | 永远通过rotation设置朝向 |
| 低帧率下转向变慢 | 插值t值未做帧率补偿 | 检查t值计算方式 | 用指数形式或乘deltaTime |
提示:排查转向问题时,最有效的方法是在
OnDrawGizmos里画出当前朝向和目标朝向的射线。这样你能直观地看到角度差和转向路径,比看数字快得多。
6. 扩展思路:从2D转向到更复杂的旋转控制
6.1 带加速度的转向:让手感更有“重量感”
恒定转速的转向适合大多数游戏,但如果你想要更有“重量感”的手感,可以给转向加速度。思路是维护一个当前角速度变量,每帧根据目标角度差来加速或减速。
private float currentAngularSpeed = 0f; [SerializeField] private float angularAcceleration = 720f; [SerializeField] private float maxAngularSpeed = 540f; void Update() { // ... 计算targetAngle ... float currentAngle = transform.eulerAngles.z; float angleDiff = Mathf.DeltaAngle(currentAngle, targetAngle); float desiredSpeed = Mathf.Sign(angleDiff) * maxAngularSpeed; currentAngularSpeed = Mathf.MoveTowards(currentAngularSpeed, desiredSpeed, angularAcceleration * Time.deltaTime); float newAngle = currentAngle + currentAngularSpeed * Time.deltaTime; transform.rotation = Quaternion.Euler(0, 0, newAngle); }这里用了Mathf.DeltaAngle来计算最短角度差,它会自动处理环绕。Mathf.MoveTowards用来平滑地改变角速度。这种方案在转向开始时有一个加速过程,转向结束时有一个减速过程,手感更接近真实的物体转动。
6.2 多段转向:路径点之间的平滑过渡
如果你的游戏里有巡逻路径或者自动寻路,角色需要在多个路径点之间平滑转向。这时候可以把每个路径点的方向作为目标,用队列或者列表管理。每到达一个路径点,就切换到下一个目标方向。插值逻辑不变,只是目标角度会定期更新。
有一个细节需要注意:当两个路径点之间的方向差很大时(比如超过120度),直接插值会导致角色走一个很大的弧线。如果路径点之间距离很近,角色可能会“绕圈”。解决办法是在路径点切换时,如果角度差超过阈值,先让角色原地转向,再开始移动。或者用贝塞尔曲线来平滑路径,让方向变化更自然。
6.3 与动画状态的配合:转向时播放哪个动画
平滑转向和动画系统的配合也是个值得聊的话题。如果你的角色有“朝左走”“朝右走”“朝上走”“朝下走”四套动画,那么转向过程中应该播放哪一套?常见做法是:当转向角度差小于某个阈值(比如30度)时,播放目标方向的动画;当角度差较大时,播放一个“转身”动画,或者继续播放当前方向的动画直到转向完成。
我一般会在角色控制器里加一个IsTurning属性,当角度差大于45度时为true。动画状态机根据这个属性来决定是否切换到转身动画。如果游戏没有专门的转身动画,那就保持当前动画,等转向完成后再切换。这样虽然视觉上有点“滑步”,但比频繁切换动画导致的闪烁要好。
6.4 在Unity新输入系统下的适配
Unity的新输入系统(Input System)和旧的Input.GetAxisRaw用法不同。如果你用的是新系统,读取输入的方式大概是:
using UnityEngine.InputSystem; private Vector2 moveInput; void OnMove(InputValue value) { moveInput = value.Get<Vector2>(); }然后在Update里用moveInput替代Input.GetAxisRaw。逻辑完全一样,只是输入来源变了。新输入系统的好处是支持更多设备,而且可以方便地做按键重绑定。如果你的项目还在用旧输入系统,也不用急着换,两者在功能上对于这个需求来说没有本质区别。
7. 一些踩坑之后的个人体会
做2D转向这件事,看起来简单,但细节非常多。我最早实现的时候觉得“不就是转个角度吗”,结果在实际项目里遇到了各种问题:帧率波动导致转向速度不一致、手柄摇杆噪声导致角色抖动、角度环绕导致角色绕远路、和动画系统配合不好导致视觉闪烁。每一个问题都花了不少时间去排查。
后来我总结出一个原则:转向逻辑要独立于移动逻辑,但两者要共享同一个输入源。也就是说,输入方向既用来计算移动向量,也用来计算目标角度,但移动和转向各自用独立的参数控制。这样你可以单独调整转向速度而不影响移动速度,也可以单独调整移动速度而不影响转向手感。
另一个体会是:不要害怕用四元数。很多2D开发者觉得四元数是3D的东西,2D用角度就够了。但实际上,四元数在2D里的使用比角度更简单、更安全。你不需要理解四元数的所有数学细节,只需要知道Quaternion.Euler把角度转成四元数,Quaternion.Slerp和Quaternion.RotateTowards做插值,这就够了。底层的数学交给Unity去处理。
最后分享一个调试技巧:在Scene视图里,用Debug.DrawRay画出角色的当前朝向和目标朝向,颜色区分开。这样在Play模式下你能实时看到转向过程,比看Inspector里的数字直观得多。我到现在还保留着这个习惯,每次调转向参数的时候都会打开Gizmos。
void OnDrawGizmos() { Gizmos.color = Color.green; Gizmos.DrawRay(transform.position, transform.right * 2f); Gizmos.color = Color.red; Vector3 targetDir = new Vector3(Mathf.Cos(targetAngle * Mathf.Deg2Rad), Mathf.Sin(targetAngle * Mathf.Deg2Rad), 0); Gizmos.DrawRay(transform.position, targetDir * 2f); }这段代码在Scene视图里会画出两条射线:绿色是当前朝向,红色是目标朝向。转向过程中你能清楚地看到绿色射线追着红色射线跑,角度差一目了然。调参数的时候把这个打开,效率能提高不少。