相机是玩家观察虚拟世界的眼睛,一旦相机的运动出现顿挫感,整个画面的观感就会大打折扣。很多刚接触游戏开发的工程师在实现相机跟随时,往往直接把相机位置赋值为角色位置加上偏移量,结果角色一加速、一转向,画面立刻抖得让人眼晕。问题的根源不在于相机组件本身,而在于缺少一套合理的平滑过渡机制。本文将从原理到代码,完整梳理如何用插值与缓动函数打造顺滑的相机运动。

一、相机为什么会卡顿:直接赋值的致命缺陷
最朴素的相机跟随逻辑是每一帧执行transform.position = target.position + offset。这种方式在角色匀速直线运动时看起来还行,但只要目标的速度发生变化,比如突然起步、急停或者转向,相机就会以同样的幅度瞬间跳变。人眼对画面的突变极其敏感,尤其是相机这种承载整个视野的对象,哪怕只有几帧的跳变,也会被察觉为“抖动”或“撕裂”。
第二个容易被忽视的原因是帧率波动。假设你在Update里写了一个固定速度的跟随逻辑,比如每帧让相机前进0.1个单位。当游戏跑在60帧时相机每秒移动6个单位,而掉到30帧时每秒只移动3个单位,速度直接减半。这种随帧率变化的速度会让画面忽快忽慢,产生明显的卡顿感。所以说,任何平滑方案都必须做到帧率无关,也就是同样的参数在任何帧率下产生同样的运动曲线。
第三个原因是更新时机的错位。角色在Update中移动,相机也在Update中跟随,如果执行顺序恰好是相机先更新、角色后更新,那么相机永远在追逐上一帧的目标位置,两者之间始终存在一帧的偏差。当帧率不稳定时这个偏差会忽大忽小,表现为周期性的抖动。把相机逻辑放到LateUpdate中执行,保证角色的当帧位置已经确定,是解决这个问题的标准做法。
// 错误写法:硬跟随 + 帧率相关
void Update()
{
transform.position = target.position + offset; // 硬切,无过渡
transform.position += Vector3.forward * 0.1f; // 帧率相关,速度不稳定
}
// 正确时机:在 LateUpdate 中处理相机
void LateUpdate()
{
SmoothFollow();
}
二、插值是平滑的核心:Lerp的正确用法与常见误用
线性插值(Lerp)是相机平滑最常用的工具,它的数学含义很简单:给定起点a、终点b和比例t,结果等于a + (b - a) * t。t为0时结果落在起点,t为1时结果落在终点,介于两者之间时产生中间值。围绕Lerp有一个流传极广的错误写法,就是让起点和终点都固定,用一个递增的t去采样,写法类似下面这样:
// 常见误用:起点固定,t随时间增长 t += Time.deltaTime / duration; transform.position = Vector3.Lerp(startPos, endPos, t);
这种写法本身没有语法错误,但它只能处理一次性的、有明确起点终点的移动,不适合持续跟随的相机。真正适合相机跟随的写法是指数平滑,也就是每帧把t设为一个固定的小比例,起点设为当前相机位置:
// 经典但有问题:帧率相关的 Lerp
void LateUpdate()
{
float t = 0.1f; // 每帧靠近目标 10%,帧率不同效果不同
transform.position = Vector3.Lerp(transform.position, desiredPos, t);
}
注意上面这段代码的注释——它每帧靠近目标10%,这在60帧和30帧下的收敛速度完全不同。要把这种写法改造成帧率无关,需要借助指数函数进行换算。假设你希望相机每秒靠近目标的比例为smooth(取值0到1之间),那么每帧的比例应该是1 - Mathf.Pow(1 - smooth, Time.deltaTime)。这个公式保证无论一帧长还是短,一秒内的总收敛量都一致:
// 帧率无关的指数平滑
public float smooth = 8f; // 阻尼系数,越大跟随越紧
void LateUpdate()
{
// deltaTime 越大,本帧靠近的比例越高,整体节奏与帧率无关
float t = 1f - Mathf.Exp(-smooth * Time.deltaTime);
transform.position = Vector3.Lerp(transform.position, desiredPos, t);
}
除了位置的Lerp,相机旋转的平滑也有讲究。如果直接对欧拉角做Lerp,角度从350度过渡到10度时会绕一大圈经过180度,出现难看的甩头现象。正确做法是对四元数使用Slerp(球面插值),它会沿旋转球面走最短路径:
// 旋转平滑:用 Slerp 而不是对欧拉角 Lerp Quaternion targetRot = Quaternion.LookRotation(target.position - transform.position); float rotT = 1f - Mathf.Exp(-rotSmooth * Time.deltaTime); transform.rotation = Quaternion.Slerp(transform.rotation, targetRot, rotT);
位置用Lerp或SmoothDamp、旋转用Slerp,这两者组合起来就能覆盖绝大多数第三人称相机的跟随需求。其中Vector3.SmoothDamp是Unity内置的阻尼平滑函数,它带有速度状态,能产生比指数Lerp更自然的加减速曲线,且几乎不会过冲,非常适合相机场景。它的第三个参数是估计到达时间(越小越快),调用时需要用成员变量保存currentVelocity供下一帧使用。
三、缓动函数:让运动曲线带上有生命力的节奏
插值解决的是“怎么靠近目标”,缓动函数(Easing Function)解决的则是“以什么节奏靠近目标”。同样是走过一段距离,匀速走过去是线性缓动EaseLinear,先慢后快是EaseIn,先快后慢是EaseOut,开头结尾都慢中间快是EaseInOut。人眼对加减速的感知非常自然,因为现实世界中所有物体的运动都遵循惯性,缓动函数正是把这种物理直觉注入到相机运动里的手段。
常见的缓动家族包括Quad、Cubic、Quart、Quint、Sine、Expo、Back、Elastic、Bounce等。前面的几个(Quad到Quint)表示多项式幂次,幂次越高加速感越强烈;Expo是指数曲线,起步极慢后段极快;Back会在到达终点前先越过终点再回弹一点;Elastic像橡皮筋一样来回震荡;Bounce则模拟小球落地的连续弹跳。相机运动中用得最多的是EaseOut系列,因为相机应该在目标停止后迅速稳定下来,而不是拖泥带水地晃悠。Elastic和Bounce虽然有趣,但用在相机上通常会让玩家眩晕,一般只出现在过场动画或特殊演出中。
下面是一个通用的缓动函数库片段,展示了几个最常用曲线的实现。注意它们都接受0到1之间的进度t,返回一个也是0到1的修正值,之后再交给Lerp去计算实际位置,这正是“缓动+插值”的标准配合方式:
public static class Ease
{
public static float Linear(float t) { return t; }
// 二次缓出:先快后慢
public static float OutQuad(float t) { return 1f - (1f - t) * (1f - t); }
// 三次缓入缓出:两端慢中间快
public static float InOutCubic(float t)
{
return t < 0.5f
? 4f * t * t * t
: 1f - Mathf.Pow(-2f * t + 2f, 3f) / 2f;
}
// 指数缓出:起步快,收尾非常柔和
public static float OutExpo(float t)
{
return t >= 1f ? 1f : 1f - Mathf.Pow(2f, -10f * t);
}
}
// 使用示例:相机在 1.2 秒内从 A 平滑移动到 B
IEnumerator MoveCamera(Vector3 from, Vector3 to, float duration)
{
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.deltaTime;
float t = Mathf.Clamp01(elapsed / duration);
transform.position = Vector3.LerpUnclamped(from, to, Ease.OutQuad(t));
yield return null;
}
transform.position = to;
}
这里有一个细节值得展开:LerpUnclamped与Lerp的区别。标准Lerp会把t钳制在0到1之间,而LerpUnclamped允许t超出这个范围。Back和Elastic这类缓动曲线的返回值会超出0到1区间,产生过冲效果,此时必须用LerpUnclamped,否则过冲部分会被硬生生裁掉,曲线末端会出现平台,反而破坏了流畅感。
四、进阶细节:变帧率、鼠标控制与镜头碰撞的平滑处理
现代游戏普遍采用可变帧率渲染,甚至有的项目会把逻辑帧与渲染帧分离,这时简单的DeltaTime平滑可能还不够。一个进阶方案是按固定时间步长模拟相机状态,把每帧多余的DeltaTime累积起来按固定步长推进,渲染时再对相机状态做一次残差插值。这能有效消除高帧率下的微小抖动,代价是实现复杂度上升。对于绝大多数中小项目,前文的指数平滑公式加上LateUpdate时机控制已经足够。
鼠标控制视角是另一个平滑的重灾区。玩家甩动鼠标时原始输入往往带有噪声,直接用原始增量驱动相机会让画面生硬。常见做法是对输入量先做一次灵敏度缩放,再对相机的目标角度做指数平滑,两层处理叠加后甩镜手感会明显变得“跟手又不发飘”。需要注意的是,第一人称视角的镜头通常不做平滑或者只做极轻微平滑,因为玩家的肌肉记忆依赖即时反馈,过度平滑反而会造成操作延迟感。平滑强度的取舍本质上是沉浸感与操控性的平衡。
// 第三人称鼠标视角:对目标角度做轻量平滑
float yaw = 0f, pitch = 0f;
public float lookSmooth = 15f;
public float sensitivity = 2.5f;
void Update()
{
float mx = Input.GetAxis("Mouse X") * sensitivity;
float my = Input.GetAxis("Mouse Y") * sensitivity;
yaw += mx;
pitch = Mathf.Clamp(pitch - my, -60f, 70f);
float t = 1f - Mathf.Exp(-lookSmooth * Time.deltaTime);
// 对每帧的输入增量做平滑,而不是对累计角度平滑,避免累计延迟
smoothYaw += (yaw - smoothYaw) * t;
smoothPitch += (pitch - smoothPitch) * t;
transform.rotation = Quaternion.Euler(smoothPitch, smoothYaw, 0f);
}
镜头碰撞是最后一个不能绕开的坑。相机跟随角色时如果被墙壁挡住,通常要把相机拉近,而很多实现会在拉近距离时用硬切,撞墙瞬间画面猛地一抖。正确的处理是对“碰撞修正后的距离”和“理想距离”分别维护平滑状态:拉近要快(避免穿墙),拉远要慢(避免角色一离开墙壁相机就弹回去),这种非对称平滑能让碰撞过渡几乎不被察觉。实现上只需在计算插值比例时根据当前距离与目标距离的大小关系,选用不同的阻尼系数即可。
总结一下核心要点:相机平滑的第一原则是永远不要直接赋值硬跟随;第二原则是所有平滑计算必须帧率无关,用指数换算或SmoothDamp而非固定比例;位置用Lerp或SmoothDamp、旋转用Slerp;一次性运镜搭配缓动函数营造节奏感,持续跟随用指数平滑保持稳定;最后在碰撞、输入等边界场景中采用非对称平滑。掌握这些手段后,无论你的项目是Unity、Unreal还是自研引擎,相机都能稳稳地“贴”在角色身后,输出电影级的顺滑画面。