1. 项目概述:从“飘移”到“丝滑”的移动控制
刚接触Unity的新手开发者,在实现角色移动时,第一个跃入脑海的方案往往就是监听键盘的WASD键。这听起来简单直接,但当你兴冲冲地写下transform.Translate或rigidbody.AddForce后,很快就会发现一个令人头疼的问题:按下两个方向键(比如W和D)进行斜向移动时,角色的移动速度竟然比只按一个键要快!这感觉就像开车时,同时踩下油门和刹车,车子却以1.4倍的速度冲了出去,完全违背了物理直觉和玩家的操作预期。这个看似微小的细节,恰恰是区分“玩具Demo”和“可玩项目”的一道坎,也是新手迈向成熟开发者的必经之路。
我见过太多项目卡在这个环节,导致角色操控手感怪异,玩家抱怨“飘”或者“滑”。实际上,解决这个问题并不需要高深的数学或复杂的插件,核心在于理解向量运算的基本原理,并对输入进行正确的标准化处理。本文将带你从零开始,一步步构建一个真正“丝滑”的WASD移动控制器。我们不仅会解决斜向移动的速度问题,还会深入探讨如何优化移动手感,让它响应迅速、停止干脆,适配各种游戏类型的需求。无论你是想制作一款第一人称射击游戏、俯视角RPG,还是2D平台跳跃游戏,这套移动逻辑都是通用的基石。
2. 移动系统核心设计与思路拆解
2.1 为什么斜向移动会“超速”?
要解决问题,必须先理解问题的根源。在Unity中,当我们分别处理水平(A/D)和垂直(W/S)输入时,通常会得到两个独立的浮点数,例如horizontalInput和verticalInput,它们的取值范围是[-1, 1]。
一种常见的错误实现是这样的:
void Update() { float horizontal = Input.GetAxisRaw("Horizontal"); // A/D键,值域[-1, 1] float vertical = Input.GetAxisRaw("Vertical"); // W/S键,值域[-1, 1] Vector3 moveDirection = new Vector3(horizontal, 0, vertical); transform.Translate(moveDirection * moveSpeed * Time.deltaTime); }当按下D键(右移)时,horizontal = 1,vertical = 0,合成的移动向量是(1, 0, 0),其长度(模长)为1。速度计算为:方向向量(1) * 速度(5) = 5单位/秒。
当同时按下W键(前)和D键(右)时,horizontal = 1,vertical = 1,合成的移动向量是(1, 0, 1)。这个向量的长度是多少?根据勾股定理:√(1² + 1²) = √2 ≈ 1.414。此时,速度计算为:方向向量(1.414) * 速度(5) ≈ 7.07单位/秒。
看到了吗?斜向移动的速度变成了单一方向移动速度的√2倍(约1.414倍)。这就是角色“飘”起来的数学原因:我们错误地将一个长度大于1的方向向量直接用于速度计算。在理想情况下,无论朝哪个方向移动,只要按下的“力度”(按键状态)相同,最终的速度大小应该是一致的。
注意:这里说的“向量长度”指的是数学上的模(Magnitude),它代表了向量的强度。在移动控制中,我们通常希望方向向量只是一个指示“朝向”的单位向量,其长度恒为1,实际速度由另一个独立的
moveSpeed参数控制。
2.2 正确的移动控制框架选择
在动手写代码前,我们需要根据游戏类型选择合适的物理框架。Unity主要提供了两种移动方式:
- 基于Transform的移动:直接修改物体的
Transform.position。这是最简单直接的方法,但完全忽略了物理引擎,物体可以穿墙,移动表现非常“电子游戏化”。适用于一些不需要物理交互的2D游戏、UI元素移动或简单的原型验证。 - 基于Rigidbody的移动:通过给物体的刚体组件施加力(Force)或直接修改速度(Velocity)来实现移动。这种方式会与物理引擎(碰撞、重力、摩擦力)交互,移动手感更真实、有惯性。适用于大多数3D游戏、需要物理反馈的场景。
对于新手而言,我强烈建议从Rigidbody + 修改Velocity的方式入手。虽然比直接修改Transform多了一步,但它为未来添加跳跃、碰撞、受击反馈等功能铺平了道路,是更专业和可持续的做法。本文的后续实现也将基于此方案。
2.3 输入系统的选择:旧Input vs 新Input System
Unity传统的输入系统(Input.GetAxis)简单易用,但对于复杂的按键组合、手柄支持、输入重映射等需求显得力不从心。Unity推出的新Input System则强大得多,它采用基于Action的配置,可以统一处理键盘、鼠标、手柄、触屏等多种输入设备。
对于新手项目,如果只是实现基础的WASD移动,使用旧系统Input.GetAxisRaw(立即返回-1,0,1,无平滑)或Input.GetAxis(有平滑过渡)完全足够,可以让我们更专注于移动逻辑本身。但如果你计划项目有长远的开发,或者需要支持多设备,花点时间学习新Input System是值得的投资。为了降低入门门槛,本文先使用传统的输入方式讲解核心逻辑,你可以在掌握原理后,轻松地将输入部分替换为新系统。
3. 核心细节解析与实操要点
3.1 向量标准化:解决速度问题的钥匙
解决斜向移动超速的核心操作叫做向量标准化(Normalize)。这个操作会将一个任意长度的向量,转换为一个方向相同但长度为1的单位向量。
在Unity中,你可以通过Vector3.Normalize()方法或vector.normalized属性来实现。两者的区别在于:Normalize()会修改原向量本身,而.normalized属性会返回一个新的标准化后的向量,不改变原向量。
修正后的核心逻辑如下:
void CalculateMovement() { float horizontal = Input.GetAxisRaw("Horizontal"); float vertical = Input.GetAxisRaw("Vertical"); // 1. 合成原始输入向量 Vector3 rawInput = new Vector3(horizontal, 0, vertical); // 2. 关键步骤:标准化输入向量,确保其长度为1 Vector3 direction = rawInput.normalized; // 此时,direction的长度恒为1 // 3. 用标准化后的方向向量乘以速度 Vector3 desiredVelocity = direction * moveSpeed; // 4. 应用于刚体速度(后续会详细说明) // ... }经过normalized处理后,无论rawInput是 (1,0,0)、(0,0,1) 还是 (1,0,1),得到的direction长度都是1。这样,desiredVelocity的大小就恒等于moveSpeed,彻底解决了斜向加速的问题。
3.2 处理“零输入”的边界情况
这里有一个非常重要的细节:当没有任何按键按下时,horizontal和vertical都为0,那么rawInput就是Vector3.zero(0,0,0)。对一个零向量进行标准化会发生什么?在数学上,零向量没有方向,也无法标准化。在Unity中,调用Vector3.zero.normalized会返回Vector3.zero。
这看起来没问题,但如果我们直接将这个结果用于速度计算,在某些移动实现中可能不会出错,却是一种不严谨的做法。好的编程习惯是主动处理这种边界情况:
Vector3 rawInput = new Vector3(horizontal, 0, vertical); if (rawInput.magnitude > 0.01f) // 检查输入是否有效,避免处理零向量 { Vector3 direction = rawInput.normalized; // ... 后续移动逻辑 } else { // 没有输入时,期望速度应为零 desiredVelocity = Vector3.zero; }使用magnitude(向量的长度)来判断是否有有效输入,比直接判断horizontal和vertical是否同时为0更可靠。
3.3 世界空间 vs 本地空间移动
另一个新手常踩的坑是移动方向错误。在代码new Vector3(horizontal, 0, vertical)中,这个向量是基于世界坐标系的。这意味着:
horizontal对应世界空间的X轴(通常是左右)。vertical对应世界空间的Z轴(通常是前后)。
如果你的角色模型朝向与世界Z轴一致,那没问题。但如果你的角色旋转了(比如在第三人称游戏中,角色面向摄像机前方),那么按下W键,角色会沿着世界Z轴移动,而不是朝着它面朝的方向移动,这显然不对。
解决方案是使用物体自身的朝向进行变换:
// 获取基于世界坐标的输入方向 Vector3 worldDirection = new Vector3(horizontal, 0, vertical).normalized; // 将世界空间的方向,转换到角色自身的局部空间(Local Space) // 这样,forward就代表角色面朝的前方,right代表角色的右侧 Vector3 localDirection = transform.forward * worldDirection.z + transform.right * worldDirection.x; desiredVelocity = localDirection * moveSpeed;这段代码是关键。transform.forward代表物体自身的正前方(蓝色轴),transform.right代表物体自身的正右方(红色轴)。通过将世界空间的Z输入映射到本地forward,X输入映射到本地right,我们实现了“按W向前走”,这个“前”永远是角色面朝的方向。
4. 实操过程与核心环节实现
4.1 完整脚本组件搭建
下面,我们创建一个完整的、可复用的角色移动脚本SmoothWASDMovement。我建议你创建一个新的C#脚本,并清空默认内容,粘贴以下代码。我会逐段进行详细解释。
using UnityEngine; [RequireComponent(typeof(Rigidbody))] // 确保挂载此脚本的物体必须有Rigidbody组件 public class SmoothWASDMovement : MonoBehaviour { [Header("移动参数")] [SerializeField] private float moveSpeed = 8f; // 基础移动速度 [SerializeField] private float acceleration = 50f; // 加速度,影响达到最大速度的快慢 [SerializeField] private float deceleration = 40f; // 减速度,影响停止的快慢 [SerializeField] private float velocityPower = 1.2f; // 速度曲线指数,用于调整手感 [SerializeField] private float turnSmoothTime = 0.1f; // 转向平滑时间 [Header("地面检测")] [SerializeField] private LayerMask groundLayer; // 地面所在的层级 [SerializeField] private float groundCheckDistance = 0.2f; // 地面检测射线长度 private Rigidbody rb; // 对刚体组件的引用 private Vector3 moveInput; // 存储每一帧的原始输入 private float turnSmoothVelocity; // 用于转向平滑插值的临时变量 private bool isGrounded; // 是否在地面上 void Start() { // 获取并缓存刚体组件,避免每帧都调用GetComponent,提升性能 rb = GetComponent<Rigidbody>(); if (rb == null) { Debug.LogError("SmoothWASDMovement脚本需要Rigidbody组件!"); } // 设置刚体的一些属性,以获得更好的移动手感 rb.freezeRotation = true; // 冻结旋转,防止物理碰撞导致角色翻滚 rb.interpolation = RigidbodyInterpolation.Interpolate; // 开启插值,让运动更平滑 } void Update() { // 在Update中处理输入检测,因为Input.GetAxis是每帧查询的 HandleInput(); CheckGround(); } void FixedUpdate() { // 在FixedUpdate中处理物理移动,因为这是物理更新的固定时间步长 // 将速度计算和应用放在这里,能保证物理模拟的稳定性 HandleMovement(); HandleFriction(); } }脚本结构解析:
[RequireComponent]属性:这是一个非常实用的特性。将它挂在类声明上方,Unity编辑器会在你添加此脚本时,自动为游戏物体添加所需的Rigidbody组件。如果已经存在,则什么都不做。这能避免因忘记添加组件而导致的运行时错误。- 序列化字段
[SerializeField]:将私有变量暴露在Inspector面板中,方便在编辑器中进行调试和调整,而无需修改代码。[Header]属性则用于在Inspector中创建分组标题,让参数列表更清晰。 UpdatevsFixedUpdate:这是关键点。Update的调用频率与帧率相关,不稳定。FixedUpdate则以固定的时间间隔(默认为0.02秒,50次/秒)调用,专门用于物理计算。因此,输入检测放在Update,而施加力或修改速度的操作必须放在FixedUpdate,这样才能保证物理模拟的一致性,避免出现“抽帧”或速度不稳定的情况。
4.2 输入处理与方向计算
接下来,我们实现HandleInput()方法。这个方法负责读取键盘输入,并将其转换为一个标准化的、基于角色自身朝向的移动方向。
private void HandleInput() { // 1. 获取原始轴输入 float horizontal = Input.GetAxisRaw("Horizontal"); // 立即响应,无平滑,适合快节奏游戏 float vertical = Input.GetAxisRaw("Vertical"); // 2. 将输入组合成一个平面向量(忽略Y轴) moveInput = new Vector3(horizontal, 0f, vertical); // 3. 标准化输入向量,解决斜向移动超速问题 if (moveInput.magnitude >= 0.1f) // 使用一个小的阈值,防止摇杆微小抖动被识别为输入 { moveInput.Normalize(); // 原地标准化,此时moveInput长度变为1 // 4. 计算目标朝向角度(弧度制)。Mathf.Atan2(y, x)返回的是与X轴的夹角。 // 这里我们计算的是输入方向在世界XZ平面上的角度。 float targetAngle = Mathf.Atan2(moveInput.x, moveInput.z) * Mathf.Rad2Deg; // 注意:Atan2的参数顺序是(y, x),在我们的向量中,x是horizontal,z是vertical(对应y)。 // 5. 将世界空间的角度,加上摄像机的Y轴旋转,实现相对于摄像机的移动。 // 假设你有一个主摄像机,这是第三人称/第一人称游戏的标配。 // 如果摄像机是固定的(如俯视角),可以不加这部分。 // targetAngle += Camera.main.transform.eulerAngles.y; // 6. 平滑地旋转角色朝向移动方向(可选,适用于第三人称) // 如果你希望角色模型总是面朝移动方向,可以启用以下代码。 // float angle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle, ref turnSmoothVelocity, turnSmoothTime); // transform.rotation = Quaternion.Euler(0f, angle, 0f); // 7. 将标准化后的输入方向,从世界空间转换到角色自身的朝向空间 // 这是实现“按W向前走”的核心:用角色的前方向量乘以垂直输入,右方向量乘以水平输入。 moveInput = transform.forward * moveInput.z + transform.right * moveInput.x; } else { // 没有有效输入时,将方向向量置零 moveInput = Vector3.zero; } }关键点解释:
Input.GetAxisRawvsInput.GetAxis:Raw版本没有平滑滤波,按下键立即返回-1/0/1,松开立即归零,响应极快,适合需要精准控制的动作游戏。GetAxis则有一个平滑过程,值会从0渐变到1或-1,手感更柔和,适合赛车或需要平缓转向的游戏。根据你的游戏类型选择。Mathf.Atan2函数:这是一个非常重要的数学函数,用于根据向量的X和Z分量计算其与Z轴正方向(在Unity中通常代表“前”)的夹角。Mathf.Rad2Deg是将弧度转换为角度。这段代码计算了输入方向在世界空间中的角度,为角色旋转做准备。- 方向空间转换:第7步
transform.forward * moveInput.z + transform.right * moveInput.x是灵魂所在。经过第3步标准化后,moveInput.z和moveInput.x的取值范围是[-1,1]。这个公式意味着:- 当只按W时(
moveInput.z ≈ 1, moveInput.x ≈ 0),最终方向 =transform.forward * 1,即角色正前方。 - 当同时按W和D时(
moveInput.z ≈ 0.707, moveInput.x ≈ 0.707),最终方向 =transform.forward * 0.707 + transform.right * 0.707,即角色右前方45度方向。 - 这样就完美地将键盘输入映射到了以角色自身为基准的移动方向上。
- 当只按W时(
4.3 物理移动与速度控制
现在实现HandleMovement()方法。我们将使用一种比直接设置rb.velocity更高级、手感更好的方法:基于加速度的速度插值。
直接设置速度 (rb.velocity = desiredVelocity) 会让角色瞬间达到最大速度,感觉像在冰面上滑动,停止时也瞬间静止,缺乏重量感和惯性。更好的方法是计算当前速度与目标速度的差值,然后施加一个力来填补这个差值,模拟加速度和减速度。
private void HandleMovement() { // 1. 计算目标速度向量 Vector3 targetVelocity = moveInput * moveSpeed; // 2. 计算当前速度在移动方向上的分量 // 点乘可以求出当前速度向量在目标方向上的投影长度 float currentSpeedInDirection = Vector3.Dot(rb.velocity, moveInput.normalized); // 3. 计算速度差值(我们需要加速或减速多少) float speedDifference = targetVelocity.magnitude - currentSpeedInDirection; // 4. 根据速度差值的正负,选择加速度或减速度系数 float accelerationRate = (Mathf.Abs(speedDifference) > 0.01f) ? acceleration : deceleration; // 如果速度差值很小,则使用减速度,帮助角色更快停下来。 // 5. 计算需要施加的力(或速度变化量)。这里使用一个指数项来调整手感曲线。 // Mathf.Pow(Mathf.Abs(speedDifference), velocityPower) 使得在速度差大时加速更猛,速度差小时加速更柔和。 float movementForce = Mathf.Pow(Mathf.Abs(speedDifference), velocityPower) * Mathf.Sign(speedDifference); movementForce *= accelerationRate; // 6. 将力分解到移动方向上,并应用到刚体 Vector3 forceVector = moveInput.normalized * movementForce; rb.AddForce(forceVector, ForceMode.Acceleration); // ForceMode.Acceleration表示忽略质量,直接改变加速度 }参数调优心得:
moveSpeed:基础移动速度。根据你的游戏世界尺度来设定。一个参考:在默认Unity单位下(1单位≈1米),人类步行速度约为1.5-2,跑步速度约为5-7。acceleration和deceleration:这是调整手感的精髓。acceleration越大,从静止加速到最大速度越快,感觉越“灵敏”。deceleration越大,松开按键后停止得越快,感觉越“干脆”。你可以让减速度略大于加速度,这样角色会更容易控制,不会“溜车”。velocityPower:一个非常有趣的参数。当它等于1时,力和速度差是线性关系。当它大于1时(如1.2-1.8),在速度差较大时(如从静止启动)力会更大,加速感更强;在速度差较小时(如接近最高速)力会变小,防止速度过冲。这能模拟出更真实的运动曲线。建议在1.0到2.0之间调试。
4.4 地面检测与摩擦力模拟
一个完整的移动控制器还需要知道角色是否在地面上,这对于后续实现跳跃、跌落判定至关重要。同时,我们还需要模拟地面的摩擦力,让角色在无输入时能自然停下。
private void CheckGround() { // 从角色底部(通常是脚部)向下发射一条短射线 // transform.position 是物体的中心,需要根据你的角色模型调整起点。例如,如果角色胶囊体高2米,则起点可以是 transform.position - Vector3.up * 0.9f RaycastHit hit; Vector3 rayStart = transform.position + Vector3.up * 0.1f; // 从略高于脚底的位置开始 if (Physics.Raycast(rayStart, Vector3.down, out hit, groundCheckDistance, groundLayer)) { isGrounded = true; // Debug.DrawRay(rayStart, Vector3.down * groundCheckDistance, Color.green); // 调试用,显示射线 } else { isGrounded = false; // Debug.DrawRay(rayStart, Vector3.down * groundCheckDistance, Color.red); } } private void HandleFriction() { // 只有当角色在地面上且没有输入时,才应用额外的摩擦力使其快速停止 if (isGrounded && moveInput.magnitude < 0.1f) { // 1. 计算一个与当前速度方向相反的力(摩擦力) // 这里使用简单的线性阻尼。更复杂的模拟可以考虑动/静摩擦系数。 Vector3 frictionForce = -rb.velocity * deceleration * 0.5f; // 系数0.5用于微调摩擦力强度 // 2. 确保摩擦力只在水平方向(XZ平面)起作用,不影响垂直方向(如重力) frictionForce.y = 0; // 3. 应用摩擦力 rb.AddForce(frictionForce, ForceMode.Acceleration); // 4. 当速度很小时,直接设置为零,防止微小抖动 if (rb.velocity.magnitude < 0.5f) { rb.velocity = new Vector3(0, rb.velocity.y, 0); // 只保留Y轴速度(用于重力) } } }地面检测注意事项:
groundLayer:在Inspector中,你需要指定一个Layer(如“Ground”),并将所有地面物体的Layer设置为它。这样射线只检测地面,不会检测到角色自身或其他物体。这能有效避免误判。- 射线起点和长度:
rayStart和groundCheckDistance需要根据你的角色模型大小仔细调整。起点太低可能会被卡进地面,太高则可能悬空时也被判定为接地。多使用Debug.DrawRay在Scene视图中可视化射线,这是调试的利器。 - 更稳健的检测:单条射线在边缘或斜坡容易失效。生产环境通常使用球形检测(
Physics.SphereCast)或从角色底部发射多条射线(如四角各一条)来综合判断,容错率更高。
5. 常见问题与排查技巧实录
即使代码逻辑正确,在实际运行中你仍可能遇到各种奇怪的问题。下面是我在多年开发中总结的一些典型“坑”和解决方法。
5.1 角色移动时“抖动”或“抽搐”
现象:角色移动不流畅,每帧位置有小幅跳跃。可能原因与排查:
Update和FixedUpdate混用:这是最常见的原因。确保所有直接修改Rigidbody.position、Rigidbody.velocity或Rigidbody.AddForce的代码都在FixedUpdate中。在Update中读取输入和状态,在FixedUpdate中应用物理。- 刚体插值未开启:检查Rigidbody组件的
Interpolation属性。对于由物理驱动的运动物体,建议设置为Interpolate。这会让Unity在渲染帧之间对物理位置进行平滑插值,消除因FixedUpdate频率(通常低于帧率)导致的卡顿感。 - 与其他脚本冲突:检查是否还有其他脚本(如动画控制器、摄像机跟随脚本)也在
Update中修改transform.position。永远不要用transform直接修改由物理引擎控制物体的位置,这会导致物理引擎和变换系统打架,产生抖动。所有位置变化都应通过刚体进行。
5.2 斜向移动速度“感觉”还是不对
现象:已经做了标准化,但斜向移动时,手感上似乎比直向移动“费力”或“缓慢”。可能原因与排查:
- 输入设备死区:如果你使用的是手柄摇杆,摇杆的物理结构可能导致无法精确地推到45度角。在斜向输入时,
horizontal和vertical的值可能都不是完美的0.707,而是比如(0.6, 0.6),其向量长度约为0.85,标准化后方向向量的分量约为(0.707, 0.707),但总输入“强度”感觉只有0.85。这不是代码问题,而是输入设备限制。可以考虑为摇杆输入添加一个小的死区(Dead Zone)过滤,或者对手柄输入进行特殊处理。 - 动画问题:移动速度是正常的,但角色的移动动画(如跑步动画)在斜向时播放速度没有调整,导致视觉上感觉速度慢。确保你的动画控制器根据速度向量的大小(
rb.velocity.magnitude)来混合和调整动画速度,而不是根据某个单一输入轴。
5.3 角色在斜坡上打滑或难以行走
现象:在斜坡上,角色向下滑落,或者向上行走时非常吃力。可能原因与排查:
- 移动力未考虑斜坡法线:我们的移动力
forceVector是直接施加在水平方向上的。在斜坡上,为了抵抗重力下滑,需要一部分力来抵消重力在斜坡方向的分量。一个简单的改进是,在地面检测时获取射线碰撞点的法线(hit.normal),然后使用Vector3.ProjectOnPlane方法将移动方向投影到斜坡平面上。if (isGrounded) { // 将移动方向投影到斜坡的法线平面上,使其与斜坡表面平行 Vector3 slopeDirection = Vector3.ProjectOnPlane(moveInput, hit.normal).normalized; Vector3 targetVelocity = slopeDirection * moveSpeed; // ... 后续使用 slopeDirection 代替 moveInput 进行速度计算 } - 摩擦力方向错误:之前的
HandleFriction中,摩擦力直接与速度反向。在斜坡上,这可能会不合理地阻碍角色运动。更高级的实现需要根据斜坡角度和法线来调整摩擦力的方向和大小。
5.4 性能优化与小技巧
- 缓存组件引用:在
Start或Awake中使用GetComponent并将结果存储在私有变量中(如private Rigidbody rb;)。绝对不要在Update或FixedUpdate中频繁调用GetComponent,这是性能杀手。 - 使用
CompareTag代替字符串比较:如果你用地面的Tag来判断,使用if (other.gameObject.CompareTag("Ground"))比if (other.gameObject.tag == "Ground")效率更高。 - 调试信息可视化:善用
Debug.DrawRay和Debug.DrawLine来可视化你的射线、移动方向向量、速度向量等。在Scene视图里亲眼看到这些数据,比在Console里看数字要直观一万倍。调试完毕后,记得注释掉或使用条件编译#if UNITY_EDITOR ... #endif来包裹这些调试代码,避免影响发布版本的性能。 - 参数配置化:将
moveSpeed,acceleration等所有可调参数都设为[SerializeField] private,这样你可以在Unity编辑器的Inspector面板中实时调整它们,并立即看到游戏运行效果,无需反复修改代码和重新编译。这是快速迭代和微调手感的关键。
最后,移动手感是游戏体验的核心之一,没有绝对“正确”的参数。最好的方法就是不断试玩、调整、再试玩。将脚本挂载到角色上,在场景中跑一跑,感受一下加速是否跟手,停止是否利落,转向是否顺滑。记住,代码是骨架,参数才是赋予它灵魂的肌肉。