从零构建 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?
三个原因:
velocity自带平滑——Unity 物理引擎会根据Interpolate设置在帧之间自动插值- 未来加入碰撞检测(比如摄像机不能穿建筑),
Rigidbody是必需的 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.forward 和 Transform.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 + ViewportLimitation | Controller 负责”怎么动”,Limitation 负责”动到哪” |
核心收获
- 协程不是银弹 — 适合作独立过渡过程,但频繁 Stop/Start 有开销,需要根据输入模式权衡
- 不要信任
transform.eulerAngles— 自维护角度变量是更可靠的选择 - 边界不是静态变量 — 在可缩放摄像机系统中,边界应该随高度动态变化
下一篇:《City Builder 的数据驱动之道:ScriptableObject 与网格管理》——聊聊怎么用 ScriptableObject 彻底消灭硬编码数据,以及如何设计一个支持任意尺寸建筑放置的网格系统。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
