综合 / 前端 · 2026-11-03 08:36 · 8 阅读 · 0 赞
游戏镜头设计:从跟随到防穿墙的完整指南
本文以2D平台游戏为例,详细讲解镜头跟随的设计:从最省事的一行代码出发,逐步加入平滑、死区、前瞻、平台吸附、垂直跟随、像素对齐、关卡边界、Boss战构图,最后扩展到3D镜头的锁定与防穿墙。
游戏镜头设计:从跟随到防穿墙的完整指南
玩家很少夸镜头,因为镜头做对了,根本感觉不到它。从一行把镜头钉在角色身上的代码出发,一步步加上平滑、死区、前瞻和跳跃时不晃的垂直跟随,再处理画面抖动、关卡边界,最后到 3D 镜头的锁定与防穿墙。
玩家会夸一款游戏的角色、关卡和配乐,有时连菜单都会夸上几句,却很少有人夸它的镜头。
这并不奇怪。镜头做对了,你根本感觉不到它,只会觉得这游戏玩着舒服:往前跑,看得见前面的坑;起跳时,脚下的世界稳稳当当;打 Boss,画面刚好装得下你们两个。
镜头做错了,五分钟就能让人想放下手柄。角色挪一步,整个画面跟着抖一下;跳一次,地面就沉下去又浮上来;跑到一半,前面的敌人直到快撞上了才从屏幕边缘冒出来。
这篇文章拿一个 2D 平台游戏的角色做例子:它最快每秒跑 8 格,一次能跳 3 格高。角色一行不改,我们只改镜头,从最省事的一行代码出发,一个问题一个问题地补上去。你会发现,“玩着舒服”这几个字背后,是一串可以算、也可以调的数字。
镜头用正交投影,orthographicSize 取 5,也就是竖直方向能看到 10 格。画面比例是 16:9,水平方向大约能看到 17.8 格。

左:镜头钉在角色身上,跳跃时整个世界上下翻涌;右:同一次跳跃,地面稳稳不动
01 最省事的写法:把镜头钉在角色身上
几乎每个人写的第一版镜头,都是这样:
void LateUpdate()
{
transform.position = new Vector3(
target.position.x,
target.position.y,
-10f);
}
角色走到哪,镜头就跟到哪,角色永远站在屏幕正中央。它不会跟丢,也不会出错,可玩上几分钟,毛病就一个接一个冒出来。
第一,角色的每个小动作,都变成了整个世界的动作。 玩家站在平台边缘,左右轻点方向键找落脚点,角色只挪了零点几格,屏幕上却是整片背景在来回晃。
第二,每跳一次,地面都要翻涌一次。 角色跳起 3 格,镜头也跟着升起 3 格。在玩家眼里,角色仿佛一直悬在原地,反倒是脚下的地面先沉下去、再浮上来。一关跳上几百次,容易晕的人很快就受不了了。
第三,它不给你留任何余地。 角色全速往右跑,屏幕右边缘离它只有约 8.9 格。一个坑从出现在屏幕上,到你一脚踩空,只有 1.1 秒。
接下来的几节,就是逐个解决这三个问题。
02 平滑:让镜头追上去,而不是贴上去
最先想到的改进,是不让镜头一步到位,而是每帧追上一部分距离。很多人会这样写:
cam.position = Vector3.Lerp(
cam.position,
target.position,
0.1f);
它在 60 帧下看起来恰到好处,可这个 0.1 是“每帧”追上一成,而不是“每秒”。帧率一变,镜头的手感就跟着变:60 帧时追上九成距离要 0.36 秒,掉到 30 帧要 0.73 秒,到了 144 帧的高刷屏上又只要 0.15 秒。同一份代码,在不同的手机上是三种不同的镜头。
把“每帧追一成”换成“每秒按固定的比例衰减”,就和帧率无关了:
[SerializeField]
float lambda = 6.32f;
cam.position = Vector3.Lerp(
cam.position,
target.position,
1f - Mathf.Exp(
-lambda * Time.deltaTime));
λ 越大,镜头追得越紧。追上九成距离需要 ln 10 ÷ λ 秒,λ 取 6.32 时正好是 0.36 秒,和 60 帧下每帧 0.1 的感觉一样,只是现在换到任何帧率都不变。这种写法后面会反复用到。
有了平滑,镜头不再像焊死在角色身上那样生硬。可前面那三个毛病,一个也没解决。小碎步时画面照样晃,只是晃得柔和了一点;跳跃时地面照样翻涌;第三个问题甚至更糟了,因为平滑的镜头总是落在角色身后,这一点我们讲前瞻时再细算。
03 死区:让角色在一个小框里自由走动
第一个真正的改动,是在屏幕中间画一个看不见的小框。角色在框里怎么走,镜头都不动;只有角色要走出框时,镜头才被它“推”着走。
这个框叫死区(Dead Zone)。

屏幕中央的死区:角色在框里走动时镜头不动,碰到边缘才推着镜头走
实现时,我们先不直接移动镜头,而是维护一个“镜头想看的点”,叫它 focus:
// 格,框宽 1.5 格
[SerializeField]
float deadZoneHalfWidth = 0.75f;
Vector2 focus;
void UpdateDeadZone(Vector2 target)
{
float dx = target.x - focus.x;
if (dx > deadZoneHalfWidth)
// 顶到右边框,推着走
focus.x += dx - deadZoneHalfWidth;
else if (dx < -deadZoneHalfWidth)
// 顶到左边框
focus.x += dx + deadZoneHalfWidth;
}
角色只要还在框里,focus 就纹丝不动;一旦越过框边,focus 就刚好跟到“角色贴着框边”的位置,不多也不少。
框的宽度要拿捏好。1.5 格大约是角色身宽的一倍半,正好吸收找落脚点时的小碎步。再宽下去,角色要跑出好一段才推得动镜头,玩家会觉得镜头反应迟钝,像是被角色拖着走。
死区还有一个不那么显眼的好处:画面静止的时间变多了。 画面不动的时候,玩家的眼睛才能放松下来,去看关卡本身。一直在动的画面,哪怕动得再平滑,也是一种负担。
04 前瞻:往哪跑,就给哪边多留点空间
死区治好了“晃”,却让第三个问题雪上加霜。
现在算一笔账。角色全速往右跑时,会一直顶着死区的右边框,比屏幕中线靠右 0.75 格。镜头又加了平滑,而用指数平滑去追一个匀速移动的目标,镜头会稳定地落后 v ÷ λ,也就是 8 ÷ 6.32 ≈ 1.27 格。两者一叠加,角色站在中线右边约 2 格的地方,前方只剩 6.9 格。那个坑留给玩家的反应时间,从 1.1 秒缩到了 0.86 秒。
我们真正想要的恰好相反:角色往右跑,镜头就该往右多看一段。 这叫前瞻(Look-ahead)。

前瞻:角色往右跑时,镜头向前偏移,前方留出的空间比身后多
做法是在 focus 上叠加一个朝运动方向的偏移:
// 格
[SerializeField]
float lookAheadDistance = 3f;
// 偏移变化的快慢
[SerializeField]
float lookAheadLambda = 2f;
float lookAhead;
void UpdateLookAhead(
Vector2 velocity, float dt)
{
// 按速度比例前瞻:
// 站着不动就不偏,全速才偏满
float target = Mathf.Clamp(
velocity.x / maxSpeed,
-1f, 1f) * lookAheadDistance;
lookAhead = Mathf.Lerp(
lookAhead, target,
1f - Mathf.Exp(
-lookAheadLambda * dt));
}
偏移 3 格以后,前方能看到的距离从 6.9 格变成 9.9 格,坑出现到脚下的时间也从 0.86 秒变成了约 1.24 秒。多出来的这将近 0.4 秒,刚好够玩家看清、判断、起跳。
写前瞻有两个讲究。
一是跟着速度走,而不是跟着按键走。 如果一按方向键就立刻偏满,玩家原地左右点一下,镜头就要甩出去 3 格再甩回来,死区好不容易挡住的晃动又回来了。按速度的比例来偏,角色站着时就不偏,跑起来才慢慢偏过去。
二是要慢,比镜头本身的跟随还要慢。 全速转身时,偏移要从 +3 格变成 −3 格,足足 6 格。要是一下子翻过去,整个画面会猛地甩向另一边。λ 取 2,偏移追上九成要 ln 10 ÷ 2 ≈ 1.15 秒,画面是从容地转过去的。
还可以再讲究一点:转身以后先别急着翻转前瞻,等角色朝新方向走出一小段,或者方向键按住超过半秒,再开始转。战斗中玩家常常左右来回转身,这样镜头就不会跟着来回甩。
05 垂直方向:跳起来的时候,世界不该跟着晃
水平方向有了死区和前瞻,垂直方向却要换一种思路。
道理很简单:往左往右走,是玩家想去某个地方;而大多数跳跃,只是为了越过一个障碍,跳完还会落回原来的高度。镜头随着每一次跳跃起起落落,完全是白费力气。
所以平台游戏的镜头,在垂直方向上通常只认一件事:角色此刻站在哪个高度的地面上。 这叫平台吸附(Platform Snapping)。

平台吸附:跳跃过程中镜头高度不变,落到更高的平台上之后才平滑地升上去
// 地面放在屏幕中线以下 2 格
[SerializeField]
float groundOffset = 2f;
// 低于地面线 1 格,就算在往下掉
[SerializeField]
float fallThreshold = 1f;
// 下落时,画面中心放在角色下方 1.5 格
[SerializeField]
float fallLookDown = 1.5f;
// 高于地面线 4 格才跟上去
[SerializeField]
float riseThreshold = 4f;
// 镜头认定的“地面高度”
float baseY;
bool falling;
void UpdateVertical(
Vector2 target, bool grounded)
{
if (grounded)
{
falling = false;
// 站稳了,才更新地面高度
baseY = target.y;
}
else if (target.y < baseY
- fallThreshold)
// 掉下去了,不能再等落地
falling = true;
else if (target.y > baseY
+ riseThreshold)
// 被弹得太高,跟上去
baseY = target.y - riseThreshold;
focus.y = falling
? target.y - fallLookDown
: baseY + groundOffset;
}
先说 groundOffset。镜头中心定在地面以上 2 格,角色的脚就落在屏幕中线下方 2 格、离屏幕底边还有 3 格的位置,头顶则留出了 7 格。一次 3 格高的跳跃,怎么也碰不到屏幕上沿。平台游戏里值得看的东西,多半在头顶和前方,脚下留 3 格就够了。
再说两个阈值。普通的跳跃最高离地 3 格,不到 4 格的 riseThreshold,所以整个跳跃过程中,baseY 一动不动,画面稳如磐石。只有落到一个更高的平台上、grounded 重新为真时,镜头才升到新的高度。
往下掉就是另一回事了。角色从平台边缘走下去时,脚下有什么,玩家必须马上看到,因为那里可能是尖刺、敌人,或者一个无底深渊。
普通的跳跃总会落回原来的高度,或者落到更高的地方。只有走下平台、掉进坑里,角色才会低于地面线。所以只要比地面线低了 1 格,就可以断定它在往下掉。这时镜头不再守着地面线,而是直接跟住角色,并把画面中心放在角色下方 1.5 格,脚下一下子能看到 6.5 格。尖刺也好,深渊也好,都会提前进入画面。
上去可以慢,下来必须快
平滑也是同样的道理。镜头往上走,是去看一个已经站稳的新平台,慢一点无妨;往下走,看的是你正在掉进去的地方,晚一帧都可能错过要命的东西。所以垂直方向的平滑要分成两个速度:
[SerializeField]
float lambdaX = 6.32f;
[SerializeField]
float lambdaUp = 3f;
[SerializeField]
float lambdaDown = 8f;
λ 为 3 时,镜头升上去追上九成要约 0.77 秒;λ 为 8 时,往下只要约 0.29 秒。
06 把零件拼起来
到这里,镜头有了三个零件:死区算出 focus.x,前瞻给出一段偏移,平台吸附算出 focus.y。把它们加起来,就是“镜头想去的位置”,最后再用平滑让镜头真正走过去:
public class PlatformerCamera
: MonoBehaviour
{
[SerializeField]
PlayerMotor player;
Vector2 smoothed;
void LateUpdate()
{
float dt = Time.deltaTime;
Vector2 target = player.Position;
UpdateDeadZone(target);
UpdateLookAhead(
player.Velocity, dt);
UpdateVertical(
target, player.Grounded);
Vector2 desired = new Vector2(
focus.x + lookAhead, focus.y);
float ly = desired.y < smoothed.y
? lambdaDown : lambdaUp;
smoothed.x = Mathf.Lerp(
smoothed.x, desired.x,
1f - Mathf.Exp(-lambdaX * dt));
smoothed.y = Mathf.Lerp(
smoothed.y, desired.y,
1f - Mathf.Exp(-ly * dt));
transform.position = new Vector3(
smoothed.x, smoothed.y, -10f);
}
}
注意水平和垂直是分开平滑的,各用各的 λ。这个细节看着不起眼,却是很多“镜头说不上哪里别扭”的根源。用一个 Vector3.Lerp 同时处理两个方向,要么水平跟得太松,要么垂直跟得太紧,怎么调都差一口气。
如果游戏里还有镜头震动,别把它和跟随写在一起。把震动放在镜头的子物体上,跟随逻辑留在父物体上:父物体负责“去哪”,子物体只管在原地晃。两件事互不干扰,震完以后,镜头会准确地回到它该在的位置。
07 画面在抖:两个最常见的原因
镜头写好了,一跑起来,却发现角色在抖。不是镜头在晃,而是角色本身一顿一顿地颤。这几乎是每个 2D 项目都会撞上的坑,原因通常是下面两个之一。
物理每秒走 50 步,屏幕每秒画 144 帧
角色的移动一般放在 FixedUpdate 里,这样物理计算的步长是固定的。Unity 默认的物理步长是 0.02 秒,也就是每秒 50 步。可屏幕每秒要画 60 帧、120 帧,甚至 144 帧。
这意味着在 144 帧的屏幕上,大约每三帧里只有一帧,角色的位置真的变了,另外两帧它都停在原地。镜头却在 LateUpdate 里每一帧都平滑地往前走。于是在屏幕上,角色相对镜头一会儿往后缩一点,一会儿又往前蹿一下。角色每秒跑 8 格,每一步物理更新走 0.16 格,按每格 16 像素算,就是两三个像素的来回颤动,肉眼看得清清楚楚。
解决办法是让镜头跟随插值后的位置,而不是物理位置。如果角色用的是 Rigidbody2D,把它的 interpolation 设为 Interpolate,Unity 会在两次物理更新之间补出平滑的中间位置,镜头跟着 transform.position 走就行。如果移动是你自己写的,就要记下上一步和这一步的位置,按 (Time.time − Time.fixedTime) ÷ Time.fixedDeltaTime 的比例在两者之间插值。
像素画落在了像素之间
第二种抖只在像素风游戏里出现。平滑后的镜头位置可以是任意小数,比如 12.3471 格。按每格 16 像素算,镜头就停在了两个像素之间,整幅画面每一帧都要重新采样,细线忽粗忽细,边缘不停地闪。
顺手的修法,是把镜头位置对齐到像素网格。但千万别直接改 smoothed:
const float PixelsPerUnit = 16f;
// 平滑的逻辑位置保持原样,
// 只在最后输出时对齐像素
Vector2 snapped = new Vector2(
Mathf.Round(
smoothed.x * PixelsPerUnit)
/ PixelsPerUnit,
Mathf.Round(
smoothed.y * PixelsPerUnit)
/ PixelsPerUnit);
transform.position = new Vector3(
snapped.x, snapped.y, -10f);
如果把取整后的值写回 smoothed,下一帧的平滑就会从取整后的位置算起。λ 取 6.32 时,60 帧下每帧只走剩余距离的一成。剩下的距离一旦小于 0.3125 格,这一成就不到半个像素,一取整就成了零。结果镜头永远停在离目标 5 个像素的地方,再也走不到头。
逻辑上的位置用小数算,画面上的位置按像素对齐,两件事分开来做,几乎所有“取整”带来的麻烦都能这样解决。
08 让镜头读懂关卡:边界、区域与拉远
到目前为止,镜头只知道角色在哪,却不知道关卡长什么样。这会带来两个问题。
别让玩家看到关卡外面
角色走到关卡最左边时,镜头依然尽职地把它摆在中间,结果屏幕左半边露出了关卡外的一片虚空。
解决办法是限制镜头中心的活动范围。可见区域的一半,竖直方向是 orthographicSize,水平方向再乘以画面比例:
[SerializeField]
Rect levelBounds;
Vector2 ClampToBounds(Vector2 center,
Camera cam)
{
float halfH = cam.orthographicSize;
float halfW = halfH * cam.aspect;
float minX = levelBounds.xMin
+ halfW,
maxX = levelBounds.xMax - halfW;
float minY = levelBounds.yMin
+ halfH,
maxY = levelBounds.yMax - halfH;
// 关卡比屏幕还窄时,干脆居中
center.x = minX > maxX
? levelBounds.center.x
: Mathf.Clamp(center.x, minX, maxX);
center.y = minY > maxY
? levelBounds.center.y
: Mathf.Clamp(center.y, minY, maxY);
return center;
}
这一步要在平滑之前做,限制的是 desired。放到平滑之后,镜头会先冲出边界、再被硬拽回来,在关卡边缘一顿一顿的。
让关卡告诉镜头该看哪里
有些地方,光靠跟随算法是不够的。一口高高的竖井,需要镜头往下多留一些;一扇只有从远处才看得见的门,需要镜头提前偏过去一点;一间 Boss 房,需要镜头拉远,把整个场地装进画面。
这些不该写死在镜头代码里,而应该交给关卡:在场景里摆一些触发区域,角色走进去,就临时改写镜头的某几个参数,比如偏移、orthographicSize 和边界;走出来,再平滑地恢复原样。
以 Boss 战为例,最好的构图是让玩家和 Boss 始终同时在画面里。镜头中心取两者的中点,orthographicSize 则根据两者的距离来定。

Boss 战:两者离得近时镜头推近,拉开距离后镜头跟着拉远,始终把两者都装进画面
算法只有几行:
// 两边各留 3 格
[SerializeField]
float framingMargin = 3f;
[SerializeField]
float minSize = 5f, maxSize = 8f;
float FitSize(Vector2 a, Vector2 b,
float aspect)
{
float halfW = Mathf.Abs(a.x - b.x)
* 0.5f + framingMargin;
float halfH = Mathf.Abs(a.y - b.y)
* 0.5f + framingMargin;
return Mathf.Clamp(Mathf.Max(halfH,
halfW / aspect), minSize, maxSize);
}
玩家和 Boss 相距 20 格时,水平方向需要 10 + 3 = 13 格的半宽,除以 16:9 的比例,orthographicSize 约为 7.3。两者靠近,镜头就推近;拉开,镜头就拉远。但它不会小于 5,也不会大于 8,再拉远下去,角色就小得看不清自己在做什么了。
orthographicSize 本身的变化也要做平滑,而且边界限制要按变化中的尺寸来算。否则拉远的一瞬间,画面会先露出关卡外面,再被推回来。
09 3D 镜头:锁定敌人,也别钻进墙里
换到 3D 第三人称游戏,镜头多了一个维度,也多了两个 2D 里没有的难题。
锁定:让角色和敌人同时入画
按下锁定键后,镜头要把玩家和敌人同时放进画面。常见的做法是:从敌人往玩家拉一条线,把镜头放在这条线越过玩家之后的延长线上,再抬高一些、往旁边偏一点,让玩家站在画面一侧,不至于正好挡住敌人。
Vector3 toPlayer = (
player.position - enemy.position);
toPlayer.y = 0f;
Vector3 back = toPlayer.normalized;
Vector3 side = Vector3.Cross(
Vector3.up, back);
Vector3 desired = player.position
// 退到玩家身后 4.5 米
+ back * 4.5f
// 抬高到肩膀上方
+ Vector3.up * 1.8f
// 往一侧偏,让出敌人的位置
+ side * 1.2f;
Quaternion look =
Quaternion.LookRotation(
(enemy.position + player.position)
* 0.5f + Vector3.up - desired);
镜头看向两人中点稍高一点的地方。这样玩家在画面一侧的近处,敌人在画面中央的远处,谁也挡不住谁。两人拉开距离时,还可以像 Boss 战那样,按距离把镜头往后拉。
防穿墙:缩进要快,退回要慢
3D 镜头最常见的灾难,是玩家贴着墙一转身,镜头整个钻进了墙里,屏幕上只剩一片墙壁的背面。

防穿墙:从角色肩膀向镜头发出球形射线,碰到墙就把镜头拉到墙前面
解决办法是每一帧从角色的肩膀,朝镜头原本该在的位置发一个球形射线。碰到东西,就把镜头放在碰撞点前面:
[SerializeField]
float cameraRadius = 0.25f;
[SerializeField]
LayerMask obstacleMask;
float currentDistance;
void ResolveCollision(
Vector3 pivot, Vector3 dir,
float desiredDistance, float dt)
{
float allowed = desiredDistance;
if (Physics.SphereCast(
pivot, cameraRadius, dir,
out RaycastHit hit,
desiredDistance, obstacleMask))
allowed = hit.distance;
if (allowed < currentDistance)
// 往里缩:立刻
currentDistance = allowed;
else
// 往外退:慢慢来
currentDistance = Mathf.Lerp(
currentDistance, allowed,
1f - Mathf.Exp(-3f * dt));
transform.position =
pivot + dir * currentDistance;
}
这里又是一快一慢。往里缩必须立刻完成,晚一帧,玩家就会看到墙的背面;往外退却要慢慢来,否则角色从柱子后面走过时,镜头会猛地缩进来、又猛地弹出去,像在抽搐。
之所以用球形射线而不用普通射线,是因为镜头前面还有一个近裁剪面。普通射线刚好擦过墙角时,镜头本身没碰到墙,画面的边角却已经切了进去。0.25 米的半径,正好给近裁剪面留出余量。
别让玩家晕
最后说一件常被忽略的事:3D 镜头是晕动症的头号来源。常见的诱因有这么几个:视野角太窄,跟随时镜头带着滚转,走路时镜头跟着上下颠,以及镜头转得比玩家推摇杆还快。
视野角一般从 60 到 70 度起步,宽一些更不容易晕;镜头几乎永远不该绕着前后轴滚转;颠簸和震动都应该在设置里留一个开关。这些都花不了多少工夫,却能让一部分玩家把游戏玩下去。
10 一张调参表
把前面的参数收拢到一起。它们不是标准答案,只是一个不错的起点:
| 参数 | 值 | 含义 | 调大以后 |
|---|---|---|---|
| orthographicSize | 5 | 竖直方向看到 10 格 | 看得更多,角色更小 |
| 水平 λ | 6.32 | 0.36 秒追上九成 | 跟得更紧 |
| 死区半宽 | 0.75 格 | 框宽 1.5 格 | 更稳,也更迟钝 |
| 前瞻距离 | 3 格 | 全速时前方多看 3 格 | 看得更远,角色离中心更远 |
| 前瞻 λ | 2 | 转身约 1.15 秒转过去 | 转身甩得更猛 |
| 地面偏移 | 2 格 | 脚下留 3 格,头顶留 7 格 | 看到更多上方 |
| 下落阈值 | 1 格 | 低于地面线 1 格算下落 | 更晚发现在往下掉 |
| 下落前瞻 | 1.5 格 | 下落时脚下看到 6.5 格 | 看到更多下方 |
| 上升阈值 | 4 格 | 普通跳跃不触发 | 弹跳时画面更稳 |
| 向上 λ | 3 | 约 0.77 秒 | 升得更快 |
| 向下 λ | 8 | 约 0.29 秒 | 跟得更快 |
| Boss 战边距 | 3 格 | 两侧各留 3 格 | 画面更松,角色更小 |
| 防穿墙回退 λ | 3 | 缩进立刻,退回约 0.77 秒 | 退得更快,更容易抽搐 |
调镜头时,有一个习惯比调参本身更重要:把镜头的“想法”画出来。
在 Scene 视图里用 OnDrawGizmos 画出死区的框、加上前瞻之后的目标点、baseY 那条地面线,还有关卡边界。镜头的毛病,十有八九出在它“想去哪”,而不是它“怎么过去”。只盯着最终画面,你很难分清是哪一种;把这几样东西画出来,一眼就知道该动哪个参数。
写在最后
回头看,这篇文章讲来讲去,其实只有一句话:镜头是玩家的眼睛,它不该抢戏。
平滑让它不再生硬;死区让它在不必动的时候不动;前瞻让它在玩家需要看远的时候看远;平台吸附让它在一次次跳跃里稳住地平线;插值和像素对齐让画面不抖不闪;边界和区域让关卡有机会告诉它哪里值得看;防穿墙则保证它无论如何都不会挡在玩家面前。
每一招拆开来,都只是几行代码、一两个参数。可当它们全都做对的时候,玩家什么也不会说。
他们只会觉得,这游戏玩着真舒服。