Unity高性能角色控制器:从原理到自研实现与优化

📅 2026/8/3 20:53:11 👁️ 阅读次数 📝 编程学习
Unity高性能角色控制器:从原理到自研实现与优化

1. 项目概述:为什么角色控制器是Unity开发的核心

在Unity里摸爬滚打这么多年,我敢说,角色控制器是每个游戏开发者都绕不开,但又最容易“将就”过去的一个模块。新手拿到一个角色,第一反应可能就是拖一个Character Controller组件上去,调调参数,能跑能跳就完事了。但当你做的项目从Demo变成真正的产品,从单机变成多人联机,从PC端跑到移动端,你就会发现,这个看似简单的“控制器”,处处都是坑。它直接决定了你游戏的“手感”——是飘忽不定还是扎实稳健,是响应迟钝还是跟手流畅。这背后,远不止一个组件那么简单,它是一整套关于物理模拟、输入处理、状态管理和性能优化的系统工程。

今天,我们就来彻底拆解Unity角色控制器。我不会只停留在官方Character Controller组件的使用手册上,那太基础了。我们要做的是,从最基础的移动原理讲起,一步步剖析为什么默认方案在复杂场景下会力不从心,并最终构建出一套能应对各种严苛场景的高性能实现方案。无论你是正在为角色穿墙、卡顿、手感怪异而头疼的开发者,还是希望从底层理解游戏角色运动机制的进阶学习者,这篇文章都会给你带来实实在在的收获。我们会涉及输入平滑、物理交互、动画融合、网络同步(思路)以及至关重要的性能优化技巧,目标是让你打造出的角色,不仅功能完备,而且运行起来如丝般顺滑。

2. 核心原理拆解:移动的本质与默认方案的局限

要构建更好的东西,必须先理解现有方案的不足。Unity提供了两套“开箱即用”的角色移动方案:基于物理的Rigidbody和基于碰撞体的Character Controller。很多开发者对它们的理解是模糊的,而这正是诸多问题的根源。

2.1 移动的两种哲学:物理驱动与直接控制

Rigidbody(刚体)是物理引擎的亲儿子。你给它施加力(AddForce)或扭矩,物理引擎会根据质量、阻力等参数计算每一帧的速度和位置变化。它的移动是“模拟”出来的,符合牛顿定律,与其他刚体碰撞、受到爆炸冲击等效果非常自然。但问题也在于此:延迟和不可控性。物理计算需要时间,角色的移动会有惯性,你想让它立刻停下,它可能还会滑行一小段。在需要精确、即时响应玩家输入的动作游戏(如平台跳跃、格斗游戏)中,这种延迟是致命的。

Character Controller则是为角色移动特化的组件。它本质上是一个胶囊碰撞体加上一套高度定制化的移动逻辑。你通过SimpleMoveMove方法,直接指定一个速度或位移向量,控制器会帮你处理与环境的碰撞反应(如沿墙面滑动、阻止穿透)。它的响应是即时的,手感更“直接”。但它的局限在于:它不是一个刚体。它不参与标准的物理模拟,这意味着:

  1. 它不会被其他刚体推开(除非你写代码处理)。
  2. 它无法方便地受到爆炸力、风力等物理效果的影响。
  3. 它的碰撞解决相对简单,在复杂地形(如高速下的连续台阶、陡坡)上容易出问题,比如“抖动”或“卡住”。

2.2 Character Controller的“黑盒”与隐患

即使你决定使用Character Controller,直接使用其默认方法也隐藏着风险。SimpleMove会自动处理重力,但Y轴速度控制权不在你手里。Move方法给了你完全控制权,但你需要自己计算重力、跳跃速度。

更大的问题在于碰撞解析。CharacterController.Move内部执行的是“试探性移动”。它计算移动向量,检测碰撞,然后调整最终位置。但这个调整逻辑有时并不智能。例如,当角色同时撞到多个表面时,或者移动方向几乎平行于一个陡坡时,内部的法线推离计算可能导致位置修正不理想,表现为微小的抖动或意外的侧滑。

注意:一个常见的误区是,认为CharacterController.isGrounded是绝对准确的。实际上,它基于最后一帧Move调用后的碰撞状态。如果你的移动逻辑在一帧内先处理水平移动再处理垂直移动(如重力),可能在某一时刻角色已悬空,但isGrounded仍未更新,导致逻辑判断错误。可靠的接地检测需要结合射线检测(Raycast)或球形检测(SphereCast)。

2.3 性能视角的初步审视

在小型场景中,这些都不是问题。但当你的场景里有上百个NPC,每个都挂着一个Character Controller时,性能开销就开始显现了。每一帧,每个控制器都要进行碰撞检测。虽然它比完整的刚体碰撞开销小,但数量上去后,累积起来也不容忽视。更关键的是,它的更新逻辑与MonoBehaviour.Update绑定,而Update的执行顺序和频率并不稳定,这为手感的微妙差异和网络同步的困难埋下了伏笔。

所以,我们的目标很明确:我们需要一个响应即时如Character Controller,交互丰富如Rigidbody,同时性能高效、架构清晰的解决方案。这需要我们跳出单一组件的思维,进行自研。

3. 自研高性能角色控制器架构设计

抛弃对黑盒组件的依赖,我们自己来搭建控制器的骨架。这套架构的核心思想是分层数据驱动,将输入、逻辑、表现分离,便于维护、优化和扩展。

3.1 核心组件与职责划分

我们设计几个核心的C#类(或结构体)来承担不同职责:

  1. PlayerInputHandler(输入处理器)

    • 职责:原始输入采集与初步加工。读取Input.GetAxis,处理手柄摇杆死区,对原始输入进行平滑滤波(如使用Mathf.SmoothDamp消除键盘输入的阶跃感),输出一个规范化的、平滑的输入意图向量
    • 关键点:输入处理必须独立于帧率。使用Time.deltaTime进行平滑插值,确保在30帧和120帧下,手感保持一致。
  2. CharacterMotor(角色运动器)

    • 职责:这是控制器的核心逻辑层。它接收来自PlayerInputHandler的输入意图,以及当前的角色状态(是否接地、是否跳跃中等),结合物理参数(速度、加速度、最大速度、跳跃高度等),计算出下一帧期望的速度向量
    • 关键点:运动计算应基于物理公式。例如,水平地面移动不是直接设置速度,而是计算加速度:velocity.xz += moveInput * acceleration * Time.deltaTime,然后施加摩擦力或阻尼使其不超过最大速度。这比直接MoveTowardsLerp速度向量更能产生真实、可控的加减速感。
  3. CharacterCollider(角色碰撞器)

    • 职责:负责将CharacterMotor计算出的速度向量,转化为安全的位移,并解决与环境(Collider)的碰撞。这是我们替代CharacterController的关键。
    • 实现方案:通常采用胶囊体投射(CapsuleCast)分离轴多次解析的方法。
      • 方案A(推荐-高性能):使用Physics.CapsuleCastPhysics.SphereCast进行预检测。将本帧位移分解为若干小步长(Vector3 step = velocity * Time.deltaTime / iteration),进行迭代检测和解决。每次检测到碰撞,就将位移向量沿碰撞平面法线方向“滑出”。这种方法控制粒度细,能有效处理复杂碰撞。
      • 方案B(更精确):使用Physics.OverlapCapsule检测潜在碰撞体,然后使用Physics.ComputePenetration计算穿透深度和方向,手动解析穿透。这种方法更精确,但计算稍复杂。
    • 关键点:必须自己实现接地检测。通常从胶囊体底部向下发射一条短射线或进行球形检测,并记录碰撞点法线。通过法线可以判断是平地(法线朝上)、斜坡(法线与垂直方向夹角小于可行走角度)还是墙壁。
  4. CharacterAnimationDriver(动画驱动器)

    • 职责:根据CharacterMotor的最终状态(速度大小、是否接地、是否起跳/下落等),驱动Animator控制器中的参数,控制动画的播放、融合和过渡。
    • 关键点:动画参数也应平滑过渡。不要直接animator.SetFloat(“Speed”, velocity.magnitude),而是使用Mathf.MoveTowardsMathf.Lerp进行插值,避免动画突变。将计算逻辑与表现逻辑解耦,未来可以轻松替换动画系统或实现预测动画(用于网络同步)。

3.2 数据驱动与参数配置

将所有可调参数(如行走速度、奔跑速度、加速度、跳跃力、重力系数、空中控制力、摩擦力等)抽离出来,放入一个CharacterSettings的ScriptableObject中。这样做的好处是:

  • 无需代码:策划或设计师可以直接在Unity编辑器内调整参数,快速迭代手感。
  • 资源化管理:可以为不同的角色(如轻甲刺客、重甲战士)创建不同的参数配置资产。
  • 易于测试:可以快速A/B测试不同参数组合的效果。

3.3 更新循环的抉择:Update vs. FixedUpdate

这是另一个关键决策点。Update与渲染帧同步,频率不稳定。FixedUpdate与物理帧同步,频率固定(默认0.02s,50Hz)。

  • 输入采集:必须在Update中,因为输入事件是与渲染帧绑定的。
  • 运动计算与碰撞检测强烈建议放在FixedUpdate。原因有三:
    1. 稳定性:固定时间步长保证了物理和运动计算的确定性,无论帧率高低,角色的移动距离和速度都是稳定的,这是良好手感的基础。
    2. 同步性:如果未来要集成网络同步,固定时间步长是实现确定性模拟和状态同步的前提。
    3. 性能:可以将密集的碰撞检测计算限制在固定的物理帧中,避免在每一渲染帧都执行。

那么,Update中采集的输入如何给FixedUpdate用?常用的模式是:在Update中将处理后的输入意图存储到一个变量中(如_fixedUpdateInput),在FixedUpdate中读取并使用它,然后清空或标记为已消费。对于FixedUpdate可能在一帧内执行多次的情况,需要妥善处理输入累积或插值。

4. 关键模块实现细节与避坑指南

架构搭好了,现在我们深入每个模块,看看具体怎么实现,以及会遇到哪些“坑”。

4.1 输入处理:从原始信号到平滑意图

public class PlayerInputHandler : MonoBehaviour { public Vector2 MoveInput { get; private set; } public bool JumpPressed { get; private set; } [Header(“平滑设置”)] public float inputSmoothTime = 0.1f; private Vector2 _currentInput; private Vector2 _inputVelocity; // 用于SmoothDamp void Update() { // 1. 采集原始输入 Vector2 rawInput = new Vector2(Input.GetAxisRaw(“Horizontal”), Input.GetAxisRaw(“Vertical”)); // 2. 应用摇杆死区(对手柄很重要) rawInput = ApplyDeadzone(rawInput, 0.1f); // 3. 规范化并平滑 if (rawInput.magnitude > 1f) rawInput.Normalize(); _currentInput = Vector2.SmoothDamp(_currentInput, rawInput, ref _inputVelocity, inputSmoothTime); // 4. 输出最终输入意图 MoveInput = _currentInput; // 5. 处理跳跃按钮(注意:要处理“按下”事件,而不是状态) JumpPressed = Input.GetButtonDown(“Jump”); } private Vector2 ApplyDeadzone(Vector2 input, float deadzone) { if (input.magnitude < deadzone) return Vector2.zero; // 可选:将死区范围重新映射到0-1之间,获得更线性的响应 return input.normalized * ((input.magnitude - deadzone) / (1f - deadzone)); } }

实操心得Input.GetButtonDownFixedUpdate中调用不可靠,因为它可能错过短暂的帧间按下事件。最佳实践是在Update中捕获这些瞬时输入,并设置一个标志位(如_jumpRequested),在FixedUpdate中消费这个标志位。消费后立即重置,确保一帧物理更新只响应一次跳跃请求。

4.2 运动计算:模拟真实的动力学感觉

CharacterMotorFixedUpdate中:

void FixedUpdateCharacter(float deltaTime) { // 1. 读取输入和状态 Vector2 moveInput = _inputHandler.MoveInput; bool isGrounded = _collider.IsGrounded; bool jumpRequested = _inputHandler.ConsumeJumpRequest(); // 消费跳跃请求 // 2. 处理水平地面移动(基于加速度和摩擦力) Vector3 targetVelocity = new Vector3(moveInput.x, 0, moveInput.y) * _settings.MaxSpeed; Vector3 currentHorizontalVel = new Vector3(_velocity.x, 0, _velocity.z); // 计算加速度方向上的速度增量 Vector3 accelerationDir = (targetVelocity - currentHorizontalVel).normalized; float acceleration = _settings.Acceleration; // 如果试图减速(目标速度小于当前速度),使用更高的减速度 if (targetVelocity.sqrMagnitude < currentHorizontalVel.sqrMagnitude) { acceleration = _settings.Deceleration; } currentHorizontalVel += accelerationDir * acceleration * deltaTime; // 钳制速度,确保不超过最大速度,且不低于0(当减速时) if (currentHorizontalVel.sqrMagnitude > targetVelocity.sqrMagnitude) { currentHorizontalVel = targetVelocity; } // 3. 处理垂直速度(重力与跳跃) if (isGrounded) { // 接地时,施加一个向下的微小速度(称为“接地锚定力”),防止在斜坡上抖动 _velocity.y = -_settings.GroundStickForce; if (jumpRequested) { // 跳跃:根据公式 v = sqrt(2 * g * h) 计算初速度 _velocity.y = Mathf.Sqrt(2f * _settings.JumpHeight * Mathf.Abs(_settings.Gravity)); _isGrounded = false; // 立即标记为未接地 } } else { // 空中:持续施加重力 _velocity.y += _settings.Gravity * deltaTime; // 空中控制:允许玩家在空中轻微调整水平方向,但力度远小于地面 if (moveInput.sqrMagnitude > 0.01f) { Vector3 airControlAccel = new Vector3(moveInput.x, 0, moveInput.y) * _settings.AirControlForce; currentHorizontalVel += airControlAccel * deltaTime; // 空中最大速度可能不同于地面 currentHorizontalVel = Vector3.ClampMagnitude(currentHorizontalVel, _settings.MaxAirSpeed); } } // 4. 合并速度 _velocity.x = currentHorizontalVel.x; _velocity.z = currentHorizontalVel.z; // 5. 将最终速度传递给碰撞器进行位移 _collider.Move(_velocity * deltaTime); // 6. 碰撞器移动后,更新速度(例如,撞墙后水平速度应被抵消) _velocity = _collider.GetAdjustedVelocity(_velocity, deltaTime); }

4.3 碰撞解析:自己动手,丰衣足食

CharacterCollider.Move方法是技术核心。这里给出一个基于射线迭代的简化版思路:

public void Move(Vector3 displacement) { int iterations = 4; // 迭代次数,提高精度 float iterationDisplacement = displacement.magnitude / iterations; if (iterationDisplacement <= 0) return; Vector3 direction = displacement.normalized; Vector3 remainingDisplacement = displacement; for (int i = 0; i < iterations; i++) { float moveDistance = Mathf.Min(iterationDisplacement, remainingDisplacement.magnitude); Vector3 moveStep = direction * moveDistance; // 使用CapsuleCast检测碰撞 if (Physics.CapsuleCast(…, moveStep.normalized, out RaycastHit hit, moveDistance + skinWidth)) { // 1. 处理碰撞:沿碰撞平面法线滑出 Vector3 slidePlaneNormal = hit.normal; Vector3 projectedMove = Vector3.ProjectOnPlane(moveStep, slidePlaneNormal); // 2. 再次检测滑动后的路径是否可行 if (projectedMove.magnitude > 0.001f && !Physics.CapsuleCast(…, projectedMove.normalized, projectedMove.magnitude)) { transform.position += projectedMove; remainingDisplacement = Vector3.ProjectOnPlane(remainingDisplacement, slidePlaneNormal); } else { // 滑动也被阻挡,只移动至碰撞点 transform.position += moveStep.normalized * (hit.distance - skinWidth); // 将剩余位移在该方向上的分量清零 remainingDisplacement -= Vector3.Project(remainingDisplacement, direction); } // 更新方向(因为剩余位移可能已改变方向) if (remainingDisplacement.magnitude > 0.001f) { direction = remainingDisplacement.normalized; } else { break; } } else { // 无碰撞,直接移动 transform.position += moveStep; remainingDisplacement -= moveStep; } if (remainingDisplacement.magnitude < 0.001f) break; } // 移动完成后,更新接地状态(从底部发射射线检测) UpdateGroundStatus(); }

避坑指南skinWidth(皮肤宽度)是一个关键参数,它是一个很小的值(如0.01f),用于在胶囊体周围创建一个微小的缓冲区。没有它,角色可能会因为浮点精度问题在连续碰撞中“颤动”或“卡进”几何体。但设置过大又会导致角色与地面“浮空”。

4.4 动画状态驱动:让动作跟上逻辑

动画驱动器的核心是平滑插值和状态同步。不要在FixedUpdate中直接设置Animator参数,因为Animator的更新通常与Update同步。我们可以在Update中,根据CharacterMotor提供的当前状态进行插值。

void Update() { // 从CharacterMotor获取状态数据(这些数据在FixedUpdate中被更新) float currentSpeed = _characterMotor.HorizontalSpeed; bool isGrounded = _characterMotor.IsGrounded; bool isJumping = _characterMotor.IsJumping; // 平滑动画参数 _smoothedSpeed = Mathf.MoveTowards(_smoothedSpeed, currentSpeed, speedSmoothTime * Time.deltaTime); _animator.SetFloat(“Speed”, _smoothedSpeed); // 直接设置布尔或触发器(不需要平滑) _animator.SetBool(“IsGrounded”, isGrounded); if (_characterMotor.JumpTriggeredThisFrame) { _animator.SetTrigger(“Jump”); } // 计算转向(可选):根据速度方向与角色当前朝向的夹角,设置Turn参数 if (_smoothedSpeed > 0.1f) { Vector3 flatVel = new Vector3(_characterMotor.Velocity.x, 0, _characterMotor.Velocity.z); float angle = Vector3.SignedAngle(transform.forward, flatVel.normalized, Vector3.up); _animator.SetFloat(“Turn”, angle / 180f); // 归一化到[-1, 1] } }

5. 性能优化与高级技巧

当你的角色控制器运行起来后,就要考虑如何让它跑得更快、更稳,尤其是在移动端或多人游戏场景下。

5.1 分层碰撞检测与图层管理

不要让你的角色控制器检测所有碰撞体。通过Unity的Layer(图层)系统进行优化:

  1. 创建专用图层:如“Player”、“Ground”、“Enemy”、“TriggerOnly”。
  2. 设置碰撞矩阵:在Edit -> Project Settings -> Physics中,只勾选必要的交互。例如,“Player”只与“Ground”、“Enemy”和某些“Trigger”层碰撞,不与“Player”(其他玩家)或“Debris”(碎片)层碰撞。
  3. 在代码中指定图层:在所有射线检测(Raycast)、胶囊体检测(CapsuleCast)中,使用LayerMask参数,只检测相关图层。这能大幅减少物理引擎需要处理的碰撞对数量。
// 定义图层掩码 [SerializeField] private LayerMask _groundLayerMask; // 在检测中使用 Physics.CapsuleCast(…, _groundLayerMask);

5.2 避免每帧的GetComponent和Find调用

StartAwake中缓存所有需要的组件引用。

private Rigidbody _rb; private Animator _animator; private PlayerInputHandler _inputHandler; void Awake() { _rb = GetComponent<Rigidbody>(); _animator = GetComponentInChildren<Animator>(); _inputHandler = GetComponent<PlayerInputHandler>(); // 如果InputHandler可能在别的物体上,使用FindObjectOfType要谨慎,最好通过序列化字段在Inspector中拖拽赋值。 }

5.3 使用对象池处理频繁的射线检测

如果你的角色需要大量射线进行环境感知(如多个方向的障碍物检测、楼梯检测等),频繁创建RaycastHit结构体会有GC(垃圾回收)开销。虽然Raycast非分配版本(Raycast(Ray, out RaycastHit, distance, layerMask))本身不产生GC,但如果你需要存储或处理这些结果,可以考虑使用一个可重用的RaycastHit数组或列表。

5.4 为大量NPC实现简化的控制器

对于非玩家角色(NPC),你可能不需要玩家角色那样精细的手感和复杂的输入处理。可以做一个“简化版”控制器:

  • 运动计算:可以直接使用NavMeshAgent进行路径寻找,或者使用更简单的Vector3.MoveTowards进行移动。
  • 碰撞检测:可以使用更粗糙的检测,比如降低迭代次数,或者使用Physics.OverlapSphere进行周期性检测而非每帧检测。
  • 更新频率:可以不用每帧更新,而是采用一个较低的固定频率(如10Hz)进行移动和决策计算,这被称为“节流更新”(Update Throttling)。

5.5 网络同步的考量

如果你计划做多人游戏,角色控制器的设计从一开始就要考虑网络同步。这通常涉及两种模式:

  • 客户端预测+服务器校验:对于玩家自己的角色,在客户端立即响应输入并移动(预测),同时将输入发送给服务器。服务器运行权威的物理模拟,并定期将校正后的状态发回客户端。客户端平滑地插值到权威状态。这要求你的运动计算是确定性的(在相同输入和初始状态下,结果永远相同)。
  • 服务器权威:对于其他玩家控制的角色或NPC,你只接收服务器发送的位置和状态更新,然后在客户端进行插值渲染。

为了实现确定性,你的FixedUpdate逻辑必须使用固定的deltaTimeTime.fixedDeltaTime),并且避免使用任何与帧率或随机种子(除非同步)相关的非确定性计算。

6. 常见问题排查与调试技巧

即使按照最佳实践,开发过程中也难免遇到问题。这里是一些常见“症状”及其排查思路。

问题现象可能原因排查与解决方案
角色在斜坡上抖动或弹跳接地检测不稳定;皮肤宽度太小;垂直速度处理不当。1. 加长接地检测射线的长度。2. 适当增加skinWidth。3. 确保接地时,垂直速度被重置为一个微小的负值(接地锚定力),而不是0。
角色卡在墙角或两个斜面之间碰撞迭代次数不足;碰撞解决算法在复杂法线情况下失效。1. 增加Move函数中的迭代次数(如从4次增加到8次)。2. 在碰撞解决时,考虑累积多个碰撞法线,计算一个综合的排斥方向。3. 实现“边缘保护”,当检测到角色被挤压在狭小空间时,尝试将其向安全方向“推”出。
移动手感“滑”或“飘”摩擦力或减速度设置过小;速度没有正确钳制。1. 增加Deceleration(减速度)参数。2. 检查速度钳制逻辑,确保在无输入时,速度能衰减到0。3. 尝试使用Mathf.SmoothDamp对计算出的目标速度本身进行平滑,而不是直接应用。
跳跃感觉不跟手或双击跳跳跃输入检测在FixedUpdate中丢失;跳跃状态机逻辑有误。1. 确保跳跃输入在Update中捕获,并通过标志位传递给FixedUpdate消费。2. 实现一个短暂的“跳跃缓存”窗口(如0.1秒),如果玩家在落地前几帧按了跳跃,落地后自动执行跳跃。3. 引入“跳跃状态”(如JumpState.Grounded, JumpState.Jumping, JumpState.Falling),严格管理状态转换。
高速移动时穿墙单帧位移过大,导致射线检测从起点直接“越过”了薄墙。1. 增加碰撞检测的迭代次数,确保单步位移足够小。2. 使用Physics.SphereCastCapsuleCast时,其半径参数应能覆盖角色体积,确保不会从缝隙中穿过。3. 对于已知的高速物体,可以启用Rigidbody的连续碰撞检测(CCD),但对于自研控制器,需要自己实现类似“扫掠检测”的逻辑。
性能开销大角色数量多;碰撞检测图层设置不当;每帧进行不必要的计算。1. 使用Profiler的Physics模块查看最耗时的碰撞检测。2. 严格优化图层碰撞矩阵。3. 对于远处的或屏幕外的NPC,降低其控制器的更新频率(如每2-3帧更新一次)。

调试技巧

  • 绘制调试图形:在OnDrawGizmos中,用Gizmos.DrawRay绘制你的接地检测射线,用Gizmos.DrawWireSphere绘制检测范围,用不同颜色表示状态(如绿色接地,红色悬空)。这能让你直观地看到检测是否准确。
  • 记录关键变量:在复杂逻辑处,使用Debug.Log输出速度、输入、碰撞点等信息。但记得发布时要移除或禁用这些日志。
  • 使用自定义编辑器脚本:为你的CharacterSettingsScriptableObject创建自定义Inspector界面,将关键参数以滑动条、曲线图的形式展示,并提供一个“实时预览”或“重置到默认值”的按钮,能极大提升调试效率。

构建一个高性能、手感好的角色控制器是一个不断迭代和打磨的过程。它没有唯一的正确答案,只有最适合你项目需求的解决方案。从理解原理开始,搭建一个清晰、可扩展的架构,然后深入每个细节模块进行实现和优化,最后通过大量的测试和调试来微调手感。这套自研的方案不仅能解决你当前项目的问题,其背后蕴含的架构思想、物理知识和优化技巧,将会成为你作为Unity开发者非常宝贵的经验财富。当你再看到任何游戏里角色的移动,你都能大概猜到它背后是如何实现的了,这种感觉,才是真正的进阶。