2395 字
6 分钟
从零构建 City Builder:流畅的 RTS 摄像机系统
2026-06-26

从零构建 City Builder:流畅的 RTS 摄像机系统#

City Builder 是笔者边学 Unity 边开发的一款模拟经营游戏。这是项目第一篇博客,分享摄像机控制系统的完整设计与踩坑记录。


一、我们要解决什么问题#

城市建造类游戏(Cities: Skylines、SimCity)的摄像机操作有三个核心需求:

操作触发体验要求
平移WASD加速有惯性,减速有缓冲,不能瞬间起停
旋转鼠标右键拖拽俯仰角有限制,不能转到地底下
缩放鼠标滚轮高度有上下限,低空时视角自动趋于平视

直觉方案是直接在 Update() 里逐帧计算:

void Update() {
transform.position += input * speed * Time.deltaTime;
}

但这样只能做出”匀速骤停”的僵硬体验。我们需要的是带过渡曲线的运动


二、骨架:为什么用协程而不是 Update + 状态机#

Update 方案的缺陷#

用 Update + 状态变量也可以模拟过渡。比如:

float currentSpeed = 0;
void Update() {
if (isMoving)
currentSpeed = Mathf.MoveTowards(currentSpeed, maxSpeed, accel * Time.deltaTime);
else
currentSpeed = Mathf.MoveTowards(currentSpeed, 0, decel * Time.deltaTime);
transform.position += direction * currentSpeed * Time.deltaTime;
}

这能跑。但当需求变复杂时——旋转需要独立的加/减速参数,缩放又要独立的平滑因子——Update 里的状态变量会野蛮生长:

// 你能想象维护这样的 Update 吗?
float moveSpeed, rotateSpeed, scrollSpeed;
float moveAccel, rotateAccel, scrollAccel;
bool isMoving, isRotating, isScrolling;
// ... 再加边界检查、俯仰角限制

每个运动维度都需要一对状态变量 + 一对配置参数。三个维度就是六对。更关键的是,加速和减速是两个独立的时间段:按下 WASD 用 accelerationTime,松开用 decelerationTime。在 Update 里区分这两个阶段意味着再多一层 if-else。

协程的优势#

协程天然适合表达”一段时间内逐步变化”的逻辑:

// 一个协程 = 一个独立的过渡过程
IEnumerator MoveCoroutine(float smoothFactor, float time, Vector3 targetInput) {
while (true) {
// 每帧插值
rb.velocity = Vector3.LerpUnclamped(rb.velocity, targetVelocity, smoothFactor);
yield return waitForFixedUpdate;
}
}

每次按下或松开按键时,停止旧协程,启动新协程。加速用加速参数,减速用减速参数,互不干扰。协程自己管理生命周期,你不需要额外的”当前处于加速还是减速阶段”标志位。

为什么用 waitForFixedUpdate 而不是 yield return null

因为我们操作的是 Rigidbody.velocity。Unity 物理更新发生在 FixedUpdate 中,如果在普通 Update 帧里修改 velocity,可能在同一物理帧内被多次覆盖,导致运动不平滑。在协程中 yield waitForFixedUpdate 能确保每次速度修改都与物理步同步。

为什么用 Rigidbody 而不是直接 transform.Translate

三个原因:

  1. velocity 自带平滑——Unity 物理引擎会根据 Interpolate 设置在帧之间自动插值
  2. 未来加入碰撞检测(比如摄像机不能穿建筑),Rigidbody 是必需的
  3. velocity 天然支持多力叠加——如果以后加入”摄像机震动”效果,直接加一个额外的力即可,不用改现有逻辑

三、平滑移动:协程 + Rigidbody + 相机的本地方向#

为什么方向要世界向量 → 本地方向?#

如果你这样写:

// ❌ 错误:世界坐标系
Vector3 moveDirection = moveInput * moveSpeed;
rb.velocity = moveDirection;

那么按下 W 键时,摄像机永远向世界坐标的 Z 轴正方向移动。当你用鼠标右键转了 90° 视角后,W 键应该让你向”屏幕前方”移动而不是世界 Z 轴。

所以需要取相机当前的本地前方和右方

// ✅ 正确:基于相机当前朝向
var forward = _camera.transform.forward; // 相机看向的方向(世界坐标)
var right = _camera.transform.right; // 相机的右侧方向(世界坐标)
// 将输入的 z(前/后)和 x(左/右)映射到世界坐标系
Vector3 worldDirection =
forward * moveInput.z + // W/S → 前后
right * moveInput.x; // A/D → 左右

注意这里用 forward 而不是 Vector3.forward。后者是固定的 (0, 0, 1),而前者随摄像机旋转而变化。

为什么不归一化 forward 和 right ?#

Transform.forwardTransform.right 返回的已经是单位向量了,不需要再 .normalized。但如果你的 _camera 对象有任何非均匀缩放,方向向量的长度就可能不是 1。更严谨的做法是:

var forward = _camera.transform.forward.normalized;
var right = _camera.transform.right.normalized;

.normalized 几乎零开销(已经是单位向量时直接返回),但能防御边缘情况。

LerpUnclamped vs MoveTowards vs SmoothDamp#

三种常见的平滑方式,选哪一个?

方法公式速度到达目标
MoveTowards(a, b, maxDelta)每帧走固定步长匀速线性能到达
Lerp(a, b, t) + 固定 t每帧走剩余距离的固定比例先快后慢指数衰减永远到不了
LerpUnclamped(a, b, t)同上但不钳制 t可控取决于 t
SmoothDamp弹簧阻尼模型过冲 + 震荡能到达

我们选了 LerpUnclamped,原因是它最简单且满足体验需求——从 0 加速到目标速度时,初期变化快、后期趋于稳定。这恰好符合我们对”加速感”的直觉:起步快,接近满速时平缓过渡。

MoveTowards 的匀速变化太”机械”了,而 SmoothDamp 虽然最真实但需要维护额外的速度状态变量,在协程模型下反而增加复杂度。

关键参数 smoothFactor * 0.1f:乘 0.1 是为了降低灵敏度。如果不乘这个系数,smoothFactor 在 1-10 的合理范围内都会让摄像机”瞬间弹射”。0.1 是一个经验值,你可以暴露为序列化字段来调参。

完整实现#

void OnMove(Vector3 moveInput)
{
isMoving = true;
if (moveCoroutine != null)
StopCoroutine(moveCoroutine);
moveCoroutine = StartCoroutine(
MoveCoroutine(moveSmoothFactor, accelerationTime, moveInput)
);
StopCoroutine(nameof(MovePositionLimitCoroutine));
}
void OnStopMove()
{
isMoving = false;
if (moveCoroutine != null)
StopCoroutine(moveCoroutine);
moveCoroutine = StartCoroutine(
MoveCoroutine(moveSmoothFactor, decelerationTime, Vector3.zero)
);
StopCoroutine(nameof(MovePositionLimitCoroutine));
}
IEnumerator MoveCoroutine(float smoothFactor, float time, Vector3 moveInput)
{
while (true)
{
var currentForward = _camera.transform.forward.normalized;
var currentRight = _camera.transform.right.normalized;
// 输入映射到世界方向
moveDirection = (
currentForward * moveInput.z +
currentRight * moveInput.x +
Vector3.up * moveInput.y
).normalized * moveSpeed;
// 平滑过渡
rb.velocity = Vector3.LerpUnclamped(
rb.velocity, moveDirection, smoothFactor * 0.1f
);
// 边界约束在另一个协程中并行运行
StartCoroutine(nameof(MovePositionLimitCoroutine));
yield return waitForFixedUpdate;
}
}

为什么 StartCoroutine 放在 while 循环里?#

MovePositionLimitCoroutine 是一个单次执行的边界检查协程(执行一次就结束)。放在 while 循环中意味着每一帧都启动一个新的边界检查。这不是最优方案——更好的做法是直接在 MoveCoroutine 中调用一次边界钳制方法。只是当时快速实现的选择,后文会讨论改进思路。


四、视角旋转:欧拉角陷阱与俯仰角限制#

为什么用欧拉角而不是四元数直接算#

旋转的直觉实现:

// 按鼠标移动量旋转
transform.Rotate(Vector3.up, mouseX * sensitivity, Space.World); // 水平
transform.Rotate(Vector3.right, mouseY * sensitivity, Space.Self); // 垂直

这种写法的问题是 transform.Rotate 是累积的。你每次微小的旋转都会与当前的旋转状态复合,而 Unity 内部用四元数存储旋转。从四元数 → 欧拉角 → 再复合回四元数的过程中,会产生 万向节死锁角度漂移

解决方案是自己维护欧拉角变量,而不是依赖 transform.eulerAngles

float currentYRotation; // 水平旋转角度(绕 Y 轴)
float currentXRotation; // 垂直旋转角度(绕 X 轴)
IEnumerator ViewHoldCoroutine(float viewSpeed)
{
while (true)
{
currentYRotation += mouseX * viewSpeed * 0.1f;
currentXRotation -= mouseY * viewSpeed * 0.1f;
// 限制俯仰角
currentXRotation = Mathf.Clamp(
currentXRotation, minViewAngle, maxViewAngle
);
// 用自己维护的欧拉角构建四元数
_camera.transform.localRotation = Quaternion.Euler(
currentXRotation, currentYRotation, 0f
);
yield return null;
}
}

这样做的好处:

  • 不会丢失精度:每次旋转都从”绝对角度”计算,不依赖上一次的 transform.rotation
  • 钳制精确:直接 clamp currentXRotation,不会出现 clamp 了一帧但下一帧又漂移的问题
  • 旋转顺序固定Quaternion.Euler(x, y, 0) 永远先绕 X 再绕 Y,行为可预测

减号的处理#

注意这行:

currentXRotation -= mouseY * viewSpeed * 0.1f;

为什么是 减号?因为鼠标向上拖动(mouseY 为正)时,直觉上是”向上看”,摄像机应该绕 X 轴正方向旋转——但 Unity 中绕 X 轴正方向是低头,所以需要取反。


五、滚轮缩放与动态边界#

缩放#

缩放通过协程改变摄像机的 Y 坐标:

IEnumerator ScrollingCoroutine(float scrollInput)
{
while (true)
{
Vector3 newPosition = _camera.transform.position;
newPosition.y -= scrollInput * scrollSpeed * 0.1f;
_camera.transform.position = newPosition;
yield return null;
}
}

动态边界:为什么阈值随高度变化#

这是最容易忽略的细节。固定边界意味着无论摄像机飞多高,水平可移动范围都一样。但现实中:

  • 摄像机在低空 → 视野窄 → 可见范围小 → 边界应该窄
  • 摄像机在高空 → 视野宽 → 可见范围大 → 边界应该宽

实现方式:

public Vector3 MoveablePosition(Vector3 currentPosition)
{
// 第一步:高度钳制
currentPosition.y = Mathf.Clamp(currentPosition.y, minHeight, maxHeight);
// 第二步:根据高度计算动态边界阈值
float heightRatio = (currentPosition.y - minHeight) / (maxHeight - minHeight);
float threshold = Mathf.Lerp(minThreshold, maxThreshold, heightRatio);
// 第三步:在 XZ 平面内钳制位置
currentPosition.x = Mathf.Clamp(currentPosition.x, -threshold, threshold);
currentPosition.z = Mathf.Clamp(currentPosition.z, -threshold, threshold);
return currentPosition;
}

Mathf.Lerp(a, b, t)t 是高度比例。摄像机在最低高度时 t=0,threshold = minThreshold;在最高高度时 t=1,threshold = maxThreshold。中间高度线性过渡。


六、整体架构#


七、已知问题与改进方向#

问题一:低空动态俯仰角调整效果不佳#

ChangeMaxViewAngleInLowHeight 的逻辑是:高度越低,maxViewAngle 越小(视角被迫更平视)。实现思路是对,但数学细节没有调好。后续打算参考 Cities: Skylines 的做法:用一条平滑曲线(AnimationCurve)定义高度-仰角映射关系,而不是简单的线性计算。

问题二:MovePositionLimitCoroutine 重复启动#

如前所述,while 循环中每帧 StartCoroutine 一个边界检查,实际没必要。改进方案是直接在 MoveCoroutine 循环内调用一次:

// 改进后
IEnumerator MoveCoroutine(...) {
while (true) {
// ... 速度计算 ...
// 直接钳制位置
transform.position = ViewportLimitation.Instance.MoveablePosition(
transform.position
);
yield return waitForFixedUpdate;
}
}

问题三:协程的 Stop + Start 频繁开销#

每次按键变化都 StopCoroutine + StartCoroutine。在 60fps 下玩家按住 WASD 不放只触发一次,所以实际开销很小。但如果将来加入手柄摇杆连续输入(每帧值都可能不同),这种模式就不适用了,应该改为在 Update 中用一个持久协程接收每帧的输入值。


八、总结#

模块技术方案深入理解
平滑移动协程 + LerpUnclamped + Rigidbody.velocity协程管理独立生命周期,yield waitForFixedUpdate 与物理同步,velocity 自带插值
视角旋转自维护欧拉角 + Quaternion.Euler避免四元数→欧拉角转换的精度丢失和万向节死锁
缩放协程控制 Y,单例控制边界边界阈值随高度动态缩放,用 Mathf.Lerp 线性映射
架构分层CameraController + ViewportLimitationController 负责”怎么动”,Limitation 负责”动到哪”

核心收获#

  1. 协程不是银弹 — 适合作独立过渡过程,但频繁 Stop/Start 有开销,需要根据输入模式权衡
  2. 不要信任 transform.eulerAngles — 自维护角度变量是更可靠的选择
  3. 边界不是静态变量 — 在可缩放摄像机系统中,边界应该随高度动态变化

下一篇:《City Builder 的数据驱动之道:ScriptableObject 与网格管理》——聊聊怎么用 ScriptableObject 彻底消灭硬编码数据,以及如何设计一个支持任意尺寸建筑放置的网格系统。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从零构建 City Builder:流畅的 RTS 摄像机系统
https://www.losrhythm.world/posts/citybuilder/01-rts-camera-system/
作者
LosRhythm
发布于
2026-06-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录