☰
Unity摄像机跟随与转动:位置和角度分开做,告别抖动穿墙
2026/10/21 4:44:37 网站建设 项目流程

做 Unity 项目做到第二年年头,我翻回自己写的第一版第三人称摄像机代码,心里只有一个念头:这也能跑?当时逻辑很直白,目标移动我就跟着移动,目标旋转我就跟着旋转,代码拢共三四行,看起来天经地义。可真正放进游戏里,镜头要么甩得人发晕,要么被墙角卡住,要么一转起来就莫名抖动。后来我才慢慢意识到,“摄像机跟随转动”这个问题被大多数人当成一个整体来写,但它本质上应该拆成两件事:位置上的跟随,和角度上的转动。这两个逻辑各自要解决什么问题、各自动用什么数学工具、各自受什么参数影响,完全不一样。这篇文章就把这两个维度的实现思路、参数调法和踩坑过程都摊开讲一遍,给刚接触这个需求的同学一条能直接照着走的路。

1. 跟随与转动,为什么要拆成两件事来想?

1.1 常见的“挂到目标下”做法,问题出在哪

很多刚接触 Unity 的人做摄像机跟随,第一反应是把摄像机拖到角色下面当子物体,再把 position 调成(0, 2, -6)之类的偏移量。这样确实能跟,角色动镜头就动,角色回头镜头也跟着回头,看起来毫不费力。

但只要你稍微深入一点,问题就全冒出来了。第一,摄像机做子物体之后,它的旋转完全继承了角色的旋转,你没法单独控制俯仰角度。角色站在斜坡上回头,镜头就会歪着拍;角色做一个翻滚动画,摄像机也跟着天旋地转。第二,这种方案没有任何平滑可言,物理上是 1:1 贴死的同步,玩家看到的是镜头带着整个屏幕猛甩。第三,一旦后期要加碰撞避障、加视野限制、加特殊演出动作,子物体方案都会变成噩梦,因为你需要强行去改一个被父节点旋转”污染”过的 Transform,来回算坐标很容易绕晕。

这种写法本质上是把“跟随”和“转动”两件事强行捆在了一起:位置变化来自父节点位移,角度变化也来自父节点旋转。你失去了对两个维度的独立控制权。真正的项目里,摄像机的需求经常是这样的:位置要跟,但角度不一定要完全跟;或者角度要跟,但位置要做平滑、要做避障。两个需求混在一个 Transform 层级里,就只能通过删改父级关系去完成,调试起来非常痛苦。

1.2 位置逻辑与角度逻辑分开管理

正确思路其实不复杂:把摄像机逻辑拆成两个关注点。位置跟随只管一件事——算出摄像机此时此刻应该坐在哪个坐标点上,然后朝那个点做平滑移动。角度转动只管另一件事——算出摄像机此时此刻应该朝哪个方向转,然后朝那个朝向做平滑旋转。两者各自独立,最后都作用于同一个transform,但互不干扰。

先看一个只负责位置跟随的精简实现:

using UnityEngine; public class SplitFollowCamera : MonoBehaviour { public Transform target; public Vector3 followOffset = new Vector3(0f, 2f, -6f); public float moveSmooth = 5f; private Vector3 moveVelocity; private void LateUpdate() { if (!target) return; Vector3 desiredPos = target.position + target.rotation * followOffset; transform.position = Vector3.SmoothDamp( transform.position, desiredPos, ref moveVelocity, 1f / moveSmooth ); } }

这里面有个细节值得注意:target.rotation * followOffset会把偏移量根据目标的朝向旋转一遍。简单说就是:当角色转身 90 度,本来在角色背后的镜头也会跟着绕到侧后方去。这是实现“跟随”,而不是简单“钉死在一个世界坐标点”的关键。如果你想要镜头始终保持在某个固定世界方向,就不要用target.rotation * followOffset,直接用followOffset就行。这个差别,新手经常搞混。

我也建议用LateUpdate而不是Update。原因是角色和控制逻辑大概率在Update里更新位置,摄像机如果也在Update里读取目标位置,就会因为执行顺序不确定,出现一帧的误差。LateUpdate保证在这一帧所有更新逻辑跑完之后再做跟随,这样摄像机看到的永远是目标的最新状态。这是小成本大收益的习惯。

拆开之后,你还能给位置单独加平滑,给角度单独加阻尼,互不影响。做一个功能只需要改一段逻辑,不会牵一发而动全身。这个设计上的分层,比任何具体算法都重要。

2. 朝向计算的几条路线:LookRotation、Slerp 与四元数避坑

2.1 LookAt 与 LookRotation,选哪个

朝向计算是摄像机转动里最容易写对也最容易写糙的部分。市面上绝大多数教程会让你直接写transform.LookAt(target),这在 demo 里没问题,但放到正式项目里会很快遭遇瓶颈。

LookAt是一个结果型 API:它直接把当前物体的rotation设置成“看向某个点”的姿态。逻辑简单,但你不容易插手中间过程。比如你想让镜头只绕 Y 轴旋转、X 轴俯仰必须限制在某个角度范围内,或者想对最终旋转做插值平滑,用LookAt都会很别扭,因为它一步到位,没有给你插值和限制的机会。

相比之下,Quaternion.LookRotation是一个计算型 API:你给它一个 forward 方向向量,它返回一个朝向这个方向的旋转值,然后你自己决定怎么用这个值。可以是直接赋值,也可以是 Slerp 插值,还可以先拆分出欧拉角做限制再重新组合。灵活性完全不同。

我实际用起来偏好LookRotation还有一个原因:它让你把“朝向”当成数据来思考。朝向前方向量可以来自角色的移动方向、来自目标点减自身位置、来自操控摇杆输入……反正输入什么向量,就能获得对应旋转。做可复用的摄像机系统时,这个抽象比LookAt好得多。

下面这张表说清两者差异:

API返回特点适用场景
transform.LookAt(Vector3 point)无,直接改 rotation一步到位,简单粗暴Demo、调试、一次性朝向
Quaternion.LookRotation(Vector3 forward)Quaternion只算朝向,交给你处理需要插值、限角、复用的系统

使用LookRotation时还需要留意 up 方向的默认值。不传第二个参数时,它认为物体应该保持“头顶朝上”的姿态,也就是世界 Y 轴。如果目标位置和摄像机位置刚好在垂直方向重叠,也就是 forward 方向几乎平行于 up 方向时,计算结果会变得不稳定甚至突然反转。这种情况下就需要你手动传一个合适的 up 参考向量,比如角色的头顶方向,来规避正交性问题。

2.2 插值平滑:直接赋值永远让人晕镜头

不管用什么 API 算出目标朝向,直接把结果赋给transform.rotation都会产生瞬间转向,表现在画面上就是一个剧烈的视觉跳跃。人眼对快速旋转特别敏感,这也是为什么第一人称射击游戏对镜头灵敏度和平滑度的要求那么高。

平滑旋转的常用做法是用Quaternion.Slerp,也就是球面插值。它会在两个旋转之间走一条比较自然的弧线,从视觉上比Lerp更贴近旋转的本质,也更少出现中间帧发飘的问题。

public Transform target; public float rotateSpeed = 6f; private void LateUpdate() { if (!target) return; Vector3 lookDirection = target.position - transform.position; Quaternion desiredRot = Quaternion.LookRotation(lookDirection); // 1 - Exp(-x * dt) 是帧率无关的平滑系数,比直接乘 Time.deltaTime 更稳定 float t = 1f - Mathf.Exp(-rotateSpeed * Time.deltaTime); transform.rotation = Quaternion.Slerp(transform.rotation, desiredRot, t); }

这里有个很多教程不会细说的点:直接在Quaternion.Slerp里用rotateSpeed * Time.deltaTime作为插值系数,其实是会受到帧率影响的。帧率越高,单帧插值步长越小,整体平滑响应时间反而会变长。用1 - Exp(-speed * deltaTime)这种形式,可以把rotateSpeed解读成类似阻尼频率的物理含义,无论跑 30 帧还是 120 帧,给人的平滑手感都一致。这个技巧我在做战斗镜头时验证过很多次,值得记下来。

Vector3.SmoothDamp同理,它的smoothTime参数本身就是和帧率无关的设计,所以位置平滑我建议优先用SmoothDamp,不要自己手写百分比线性插值。

2.3 四元数的常见误操作

绕不开的一个坎是四元数。很多新同学看一眼Quaternion的四个分量就发懵,然后试图直接操作x, y, z, w来“微调角度”,这是最容易翻车的操作。四个分量之间的关系非常微妙,你单独改一个分量,最后得到的角度可能是完全不可预测的。别改,除非你特别清楚自己在干嘛。

另一类误操作是把角度存在欧拉角里,然后每帧做transform.rotation = Quaternion.Euler(eulerAngles)。欧拉角对开发者友好,但会遇到万向节锁。具体表现是:当俯仰角度接近正上方或正下方时,Y 轴和 Z 轴的旋转会互相抵消或者突然反转,镜头会瞬间“拧”一下。我在后面第 4 节会详细展开这个问题的排查过程,这里先记住一个原则:输入和调试时可以用欧拉角,存储和计算时尽量使用四元数。

还有一点是关于父级旋转的问题。如果摄像机节点挂在某个父物体下,父物体只要有旋转,摄像机的世界旋转就是“父级旋转乘以自身旋转”的结果。所以当你发现自己明明设置了摄像机的 rotation,但画面还是歪的,先查父节点的 rotation,别只盯着摄像机自己看。这种问题排查起来极快,但如果你不知道这个机制,能卡你一下午。

3. 三种机位的参数手感:第三人称、俯视角与横版镜头

3.1 第三人称跟随的参数配置

第三人称是目前游戏里最常见的摄像机类型,AKA 越肩视角。它的核心诉求是:镜头在角色背后偏上,能跟随角色转身,同时让玩家看到前方场景和角色本身。

参数上我通常会分三组来配。第一组是位置参数:offset的 Z 值控制在 -8 到 -4 之间,X 值根据越肩方向设为 1 或 -1,Y 值在 2 到 4 之间。数值太大,角色在屏幕上占的比例太小,打起来看不清动作;太小则挡视野。第二组是角度限制:俯仰角 pitch 要锁在 -10 度到 40 度这个区间,仰过头就会穿到角色头里面去,俯太低又变成纯俯视。Y 轴偏航可以放开,让玩家自由转动,但一般需要加一个跟随阻尼,让镜头转得比鼠标慢半拍,这样才有重量感。第三组是平滑参数:moveSmooth通常在 5 到 10,rotateSpeed在 4 到 8。如果角色奔跑速度很快,位置平滑可以适当加大,让镜头能跟上;如果角色经常做急停急转,旋转平滑反而要调低一点,避免镜头甩过头。

第三人称还有一个隐蔽需求:避障。角色贴着墙走时,镜头如果还在背后固定偏移,就会穿到墙里面去。我常用的方案是从目标位置向摄像机位置发射一条Physics.SphereCast,如果碰撞点在摄像机期望位置之前,就把摄像机强行挪到碰撞点附近。这个方案能解决大部分穿墙问题,代价是当碰撞距离非常近时,镜头会贴脸,画面很难看。这时候可以在检测到碰撞后加一个软距离,让镜头不至于完全怼到角色身后。

3.2 俯视角的离散旋转与固定限制

俯视角摄像机通常用在策略类或者管理类玩法里,它的转法和第三人称完全不同:镜头不是跟着某个角色身后转,而是绕着地图的中心点转。玩家可能只是按 Q、E 键切换旋转方向,或者拖动屏幕边缘操作视角。

这种摄像机最重要的是不能让镜头转到地面以下。我一般把俯仰角视角范围限制在 15 到 70 度之间,低于 15 度视角几乎贴地,高于 70 度又变成完全俯视,看不到建筑侧面。偏航角度可以做离散步进,比如每次旋转 45 度,这样玩家不会在自由旋转里失去方向感。平滑上则可以做一点惰性:物理操作触发目标偏航变化,摄像机旋转慢慢跟过去,营造出“画面稳定切换”的手感。

俯视角还要特别注意垂直方向的旋转。当玩家把镜头抬得很高、快要和平面平行时,四元数插值会进入不稳定区间。解决方式是先把目标角度限制在允许范围里,再转成四元数插值,不要先插值再限制。顺序反了,就会出现镜头转到某个角度后突然弹回另一侧的诡异表现。

3.3 横版侧视角的深度投影

横版游戏的摄像机看着最简单,其实同样有讲究。横版镜头通常固定在一个侧向角度上,跟随角色在 X 和 Y 方向的移动,Z 方向基本不动。这里容易忽略的是:角色朝左还是朝右,会决定镜头的朝向要不要做镜像处理。

做成纯 2D 玩法时,镜头往往不需要旋转,只要位置对齐角色就行。但如果是一个带深度纵深的 2.5D 横版场景,角色跳跃到高处或者移动到背景层时,镜头就需要小幅俯仰或偏航来保证视野。此时我会给摄像机一个“朝向目标点”的旋转,但限制旋转幅度很小,比如 Y 轴偏航限制在 -10 到 10 度,避免画面出现明显透视形变。

横版的另一个特征是角色快速换向。角色突然从朝左改成朝右,如果镜头也立刻转向,玩家会瞬间失去参照。正确的做法是让镜头的位置和角度都做平滑过渡,换向时给一个短暂的手感停顿,让玩家眼睛适应新方向。这个写起来就是普通的 Slerp,但参数上要舍得给偏慢的速度,比如rotateSpeed = 3左右,比第三人称更钝。

三种机位的参数对比如下:

机位偏航自由度俯仰限制推荐平滑速度特殊注意
第三人称360 度自由-10 到 40 度4 到 8越肩偏移、避障射线
俯视角45 度步进15 到 70 度2 到 5先限角再插值、防止贴地
横版固定或极小摆动几乎固定3 左右换向手感、深度投影

这些数值不是用来背诵的,而是调试起点。我每次接到新项目,都会先把典型值填进去跑起来,再花半天按玩感慢慢微调。摄像机的参数没有绝对正确,只有适合当前手感。

4. 抖动、穿墙与旋转死区:一次完整排查过程

4.1 现象:转视角时镜头抖动、穿墙和卡住

我这里说的不是理论推演,而是我在某项目里实际遇到的三联问题。版本上线前测试反馈:手柄转动视角时,镜头会像被什么东西吸住一样一顿一顿地抖动;角色走到墙角时,摄像机穿进墙里,屏幕一半是角色身体一半是墙内黑边;更诡异的是,当角色面向正上方的特定区域时,镜头突然整个反向旋转了 180 度,玩家直接失去方向。

这类问题在摄像机系统里特别典型,因为现象分散在两个逻辑维度上:一个属于位置跟随,一个属于朝向计算。如果不拆开排查,光看摄像机这个整体,很难定位根因。

4.2 排查顺序:从 Update 时机和多人写入开始

我先处理抖动。抖动最直接的原因是摄像机的transform每帧被多个逻辑重复写入,或者写入时机不稳定。当时第一版代码把跟随逻辑放在Update里,角色控制器也放在Update里,两者执行顺序看脚本加载顺序,结果某一帧角色先动、镜头后动,下一帧镜头先动、角色再动,画面就表现出轻微的来回抖。

排查方法是给角色和摄像机各挂一个调试脚本,在Update和LateUpdate里输出帧号与坐标值。一对比就发现,坐标总是在两帧之间来回跳。把所有摄像机逻辑移到LateUpdate后,抖动立刻消失大半;剩下一部分抖动来自另一个旧脚本里仍在修改摄像机的位置,当场删掉就干净了。记住:同一个 Transform,尽量不要让两块代码在同一个帧阶段改它。

4.3 穿墙修正:射线检测与回退距离

穿墙问题的排查链路更顺着位置逻辑走。改到LateUpdate后抖动消失了,但穿墙依然存在,说明问题出在位置计算本身。

我的修法是在算完期望位置后,从目标点向摄像机位置打一条射线:

using UnityEngine; public class CameraCollisionFix : MonoBehaviour { public Transform target; public float collisionRadius = 0.2f; public float minDistance = 1.5f; private void LateUpdate() { if (!target) return; Vector3 followOffset = new Vector3(0f, 2f, -6f); Vector3 desiredPos = target.position + target.rotation * followOffset; // 从目标位置朝期望摄像机位置做 SphereCast Vector3 direction = desiredPos - target.position; float distance = direction.magnitude; direction.Normalize(); if (Physics.SphereCast(target.position, collisionRadius, direction, out RaycastHit hit, distance)) { float hitDistance = Mathf.Clamp(hit.distance - collisionRadius, minDistance, distance); desiredPos = target.position + direction * hitDistance; } transform.position = Vector3.Lerp(transform.position, desiredPos, 5f * Time.deltaTime); } }

这里有两个容易漏掉的细节。第一个是Physics.SphereCast的移动端性能开销,如果每帧都做全场景检测,会增加一截 CPU 开支。实操上我会给障碍物专门分一个碰撞层,然后让射线的LayerMask只检测这一层,而不是默认检测所有物体。第二个细节是hit.distance要减去collisionRadius,否则摄像机模型本身一半已经卡到墙里了,视觉上还是穿模。

加上射线修正之后,我顺手写了OnDrawGizmos把射线路径可视化出来。调试摄像机碰撞时,肉眼看不出射线是不是穿过了障碍物,但 Gizmos 一画,射线哪截被挡、哪截正常,一眼就明白。这是我在摄像机调试里最推荐的招数:把你的假设画出来,让问题自己现形。

4.4 旋转死区:万向节锁定的一次实地演示

最后说那个“镜头突然反转 180 度”的问题。当时脚本是这么写的:拿到玩家手柄的输入,累加一个yaw变量,然后每帧直接transform.rotation = Quaternion.Euler(pitch, yaw, 0)。看起来很正常对吧?问题出在pitch上。

当玩家把视角抬得很高,pitch接近 90 度时,欧拉角本身会进入奇点,也就是万向节锁定。在这个角度附近,Y 轴和 Z 轴的旋转变得不可区分,稍微有一点输入扰动,Quaternion.Euler(pitch, yaw, 0)生成的朝向就会发生剧烈跳变,表现成镜头瞬间转向。要解决它,不能靠继续用欧拉角死磕,而是要把存储和计算切到四元数链路里:

Quaternion yawRotation = Quaternion.Euler(0f, yaw, 0f); Quaternion pitchRotation = Quaternion.Euler(pitch, 0f, 0f); Quaternion finalRotation = yawRotation * pitchRotation; transform.rotation = Quaternion.Slerp(transform.rotation, finalRotation, t);

把偏航和俯仰拆成两个四元数再相乘,比直接Euler(pitch, yaw, 0)更稳。同时记得限制pitch在 15 到 70 度之间,从源头避免进入垂直奇点区域。这是俯视角摄像机的基本功,第三人称如果允许大幅仰视,也需要同样处理。

排查这个 180 度反转问题,我走了不少弯路,一度以为手柄输入有问题,还换了两个测试设备。后来是在某个角度反复打日志,发现transform.eulerAngles在临界点附近会突然从(89, 180, 0)跳成(-89, 0, 0),才算真正意识到这是旋转表示方式的问题。从那以后,凡是会连续转动的摄像机逻辑,我一律用四元数作为中间状态,欧拉角只留在输入和限角层。

到这里,摄像机跟随转动的四个侧面——分工边界、朝向计算、机位参数、故障排查——就都覆盖了。我最后再多说一句个人习惯:我会给任何摄像机系统都加一个调试开关,允许运行时把当前位置、目标位置、当前朝向、目标朝向直接画在屏幕上或者画在 Scene 视图里。每次有人说“镜头有点怪”,我第一件事不是看代码,是先看这个数据面板。位置和角度分别过一遍,问题基本能锁定到维度上。这套思路帮我省了太多无头苍蝇式的时间,比任何单一技巧都值得养成。

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

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

立即咨询