
1. 从一次“手感翻车”说起为什么你的跳跃总像在月球上做Unity角色控制的人几乎都经历过同一个尴尬时刻明明给角色加了跳跃按下去却像踩在棉花上——要么轻飘飘地飘起来要么落地时“啪”地一下瞬间贴地毫无重量感。更别提那种按一下跳一下、长按却毫无变化的僵硬手感。玩家嘴上不说手指却很诚实玩两下就退了。“完美跳跃”这四个字听起来像玄学其实拆开来看全是可量化的物理参数和状态机逻辑。它要解决的核心问题就三个起跳要跟手、滞空要可控、落地要干脆。这三个词背后对应的是输入缓冲、重力曲线、可变跳跃高度、土狼时间、落地检测这一整套机制。不管你是做2D平台跳跃、3D动作游戏还是做那种带点物理感的休闲小游戏这套东西都是通用的底层能力。这篇文章适合谁看如果你已经会用Unity的CharacterController或者Rigidbody让角色动起来但跳跃手感始终差一口气那这篇就是写给你的。我会从参数背后的物理原理讲起一路拆到可直接复制的代码结构中间穿插我自己踩过的坑和调参心得。全程不依赖任何特定插件用Unity原生API就能落地。先说一个反常识的结论完美跳跃的关键不在于“跳得多高”而在于“玩家按下按键的那一瞬间角色是否立刻响应以及松开按键后角色是否立刻收力”。很多人把精力全花在调跳跃高度上结果手感依然稀烂就是因为忽略了输入与物理之间的时间耦合。2. 跳跃系统的整体设计思路把“跳”拆成四个阶段2.1 为什么不能只用一句AddForce搞定新手最容易写出的跳跃代码是这样的检测到按键给刚体加一个向上的力完事。这段代码能跑但手感一定差。原因在于它把跳跃当成了一个瞬时事件而真实的跳跃是一个有时间跨度的过程。玩家按下按键到松开按键中间可能持续几十毫秒到几百毫秒这段时间里角色的受力应该是变化的。我把一次完整跳跃拆成四个阶段起跳预备、上升、顶点过渡、下落与落地。每个阶段玩家能施加的影响不同系统需要处理的逻辑也不同。起跳预备阶段要处理输入缓冲上升阶段要处理可变跳跃高度顶点过渡要处理重力衰减下落阶段要处理落地检测和土狼时间。这四个阶段用状态机串起来才能让跳跃从“一个动作”变成“一段体验”。2.2 状态机 vs 纯物理选型背后的取舍有人会问跳跃状态机是不是过度设计我用一个布尔值判断是否在空中不行吗短期可以但一旦你要加二段跳、蹬墙跳、空中冲刺布尔值就会爆炸成一堆互相打架的标志位。状态机的价值在于把“当前处于什么状态”和“这个状态下允许做什么”分离后续扩展时只需要新增状态不用改动已有逻辑。不过状态机也有代价它引入了状态切换的时机问题。比如从“上升”切到“下落”如果切换条件写得太死比如速度小于零才切角色在顶点附近会有一段速度接近零的“悬停感”看起来像卡了一下。解决办法是给顶点一个速度阈值区间进入区间就切换状态同时施加一个额外的下坠加速度让角色快速脱离顶点。这个细节后面会展开。2.3 参数体系把“手感”翻译成数字手感这个词很虚但它对应的参数很实。我习惯把跳跃相关参数分成三组参数组包含参数作用高度组跳跃高度、重力加速度决定跳多高、多久落地时间组上升时间、下落时间、土狼时间、输入缓冲时间决定操作窗口和节奏曲线组上升重力倍率、下落重力倍率、顶点阈值决定滞空感的“形状”这三组参数不是独立的。跳跃高度和重力加速度共同决定上升时间公式是上升时间 2 × 跳跃高度 / 初速度而初速度又等于重力加速度 × 上升时间。所以你不能同时固定高度、重力和时间必须留一个作为推导值。我的习惯是固定跳跃高度和上升时间反推重力和初速度因为高度和时间是玩家最能感知的两个量。3. 核心细节拆解让跳跃“跟手”的五个关键机制3.1 输入缓冲解决“按早了没反应”玩家在落地前几帧按下跳跃键如果系统只在“着地状态”检测输入这次按键就会被吞掉。玩家会觉得“我明明按了怎么没跳”这就是输入缓冲要解决的问题。做法很简单记录最后一次按下跳跃键的时间戳在着地时检查这个时间戳是否在缓冲窗口内通常0.1到0.15秒如果在就立即起跳。这个窗口不能太长否则会出现“我没按怎么自己跳了”的诡异感也不能太短否则缓冲形同虚设。我实测下来0.12秒是一个比较舒服的值大概对应7帧60帧每秒。你可以把它做成可调参数不同游戏类型微调。3.2 土狼时间解决“刚离开平台就跳不了”角色跑出平台边缘的瞬间如果立刻失去跳跃能力玩家会觉得“我明明还在边上”。土狼时间Coyote Time就是给玩家一个宽限期离开地面后的一小段时间内仍然允许起跳。这个时间通常和输入缓冲对称也是0.1到0.15秒。实现上不要用“是否着地”这个布尔值而是用“距离上次着地过了多久”这个计时器。计时器小于土狼时间就允许跳大于就禁止。注意土狼时间只在“主动走下平台”时生效如果是被击飞或者主动跳跃后不应该再给土狼时间否则会出现空中二段跳的漏洞。区分方法是记录离开地面的原因只有“自然离开”才给宽限。3.3 可变跳跃高度解决“按一下和长按一样高”这是提升手感最明显的一个机制。玩家轻点跳跃键角色跳一个小高度按住不放角色跳满高度。实现方式是起跳时给一个初始向上的速度如果玩家在上升过程中松开了跳跃键就把当前向上的速度乘以一个衰减系数比如0.5让角色提前进入下落。这里有个坑衰减系数不能太小否则轻点和长按的区别过于剧烈玩家会觉得“我稍微松一点就掉下去了”。也不能太大否则区别不明显。我一般用0.4到0.6之间具体看游戏节奏。另外衰减只应该在上升阶段生效下落阶段松开按键不应该有额外影响。3.4 重力曲线解决“滞空感像月球”真实世界的重力是恒定的但游戏里的重力往往需要“作弊”。如果上升和下落用同一个重力值角色会在顶点附近停留过久看起来轻飘飘的。常见的做法是上升用较小的重力下落用较大的重力比例大概是1比1.5到1比2。这样角色上升时显得有力下落时显得干脆。更进一步可以在顶点附近再加一层“顶点重力倍率”让角色在速度接近零时受到更大的向下加速度快速脱离顶点区域。这个倍率我一般设1.5到2.0配合一个速度阈值比如0.5米每秒来判断是否进入顶点区域。注意这个阈值要相对于角色的最大跳跃速度来定不能写死。3.5 落地检测解决“落地瞬间贴地”用CharacterController的isGrounded属性做落地检测有时候会出现“明明还在空中却判定为着地”或者“明明着地了却还判定在空中”的情况。前者通常是因为控制器底部和地面之间有微小间隙后者是因为下落速度太快一帧内穿过了地面检测范围。解决办法是不要只依赖isGrounded而是结合速度判断只有当垂直速度小于等于零并且isGrounded为真时才认为真正落地。另外可以在角色脚下发射一条短射线做辅助检测射线长度略大于控制器底部半径这样能提前几帧预判落地让落地动画和音效更跟手。4. 实操过程从零搭建一个可调参的跳跃系统4.1 场景准备与角色配置新建一个3D场景放一个平面当地面再放一个胶囊体当角色。给角色挂上CharacterController组件调整高度和半径让胶囊体底部刚好贴地。然后新建一个脚本命名为JumpController挂到角色上。CharacterController和Rigidbody二选一。如果你要做纯物理交互比如被爆炸推飞用Rigidbody如果要做精确的平台跳跃用CharacterController。我这里选CharacterController因为它对移动的控制更直接不会出现物理引擎的意外滑动。配置参数时先把CharacterController的Slope Limit设为45度Step Offset设为0.3Skin Width设为0.08。Skin Width很关键太小会导致角色卡在地面里太大会导致悬浮感。0.08是经验值对应胶囊体半径的10%左右。4.2 参数计算从目标高度反推重力和初速度假设我想要角色跳1.5米高上升时间0.4秒。根据物理公式上升时间t_up 0.4s跳跃高度h 1.5m初速度v0 2h / t_up 2 × 1.5 / 0.4 7.5 m/s上升重力g_up v0 / t_up 7.5 / 0.4 18.75 m/s²下落时间我设为0.3秒比上升短这样下落更干脆下落重力g_down 2h / t_down² 2 × 1.5 / 0.09 ≈ 33.33 m/s²这两个重力值都远大于现实世界的9.8但在游戏里很常见。记住游戏物理是为手感服务的不是为真实服务的。把这些值填进脚本的公开字段方便在Inspector里实时调整。我习惯把上升时间、下落时间、跳跃高度作为主参数重力和初速度在Awake里自动计算这样调参时只需要改三个直观的值。4.3 状态机实现用枚举管理跳跃阶段定义一个枚举JumpState { Grounded, Rising, Apex, Falling }用一个变量记录当前状态。每帧根据垂直速度和着地情况切换状态switch (currentState) { case JumpState.Grounded: if (jumpBufferTimer 0 coyoteTimer 0) StartJump(); break; case JumpState.Rising: if (verticalVelocity apexThreshold) currentState JumpState.Apex; break; case JumpState.Apex: if (verticalVelocity -apexThreshold) currentState JumpState.Falling; break; case JumpState.Falling: if (controller.isGrounded verticalVelocity 0) currentState JumpState.Grounded; break; }注意Apex状态是一个过渡状态它的作用是施加额外的顶点重力让角色快速通过顶点区域。进入Apex时不需要做特殊处理只需要在Update里根据状态选择对应的重力值。4.4 输入处理缓冲与土狼时间的计时器写法输入缓冲和土狼时间都用计时器实现每帧递减if (Input.GetButtonDown(Jump)) jumpBufferTimer jumpBufferTime; else jumpBufferTimer - Time.deltaTime; if (controller.isGrounded) coyoteTimer coyoteTime; else coyoteTimer - Time.deltaTime;起跳条件就是jumpBufferTimer 0 coyoteTimer 0。起跳后立即把两个计时器清零防止重复触发。这里有个细节土狼时间在着地时应该重置为满值而不是累加否则连续着地会让土狼时间越来越长。4.5 可变跳跃高度的松键检测在上升状态中检测跳跃键是否松开if (currentState JumpState.Rising Input.GetButtonUp(Jump)) { verticalVelocity * jumpCutMultiplier; currentState JumpState.Falling; }jumpCutMultiplier就是前面说的衰减系数我设0.5。注意这个检测只在Rising状态生效而且只生效一次。如果玩家松开后又按下不应该再次触发衰减否则会出现“连点跳更高”的漏洞。4.6 重力应用与移动整合每帧根据状态选择重力值然后应用到垂直速度上float gravity currentState JumpState.Rising ? gUp : gDown; if (currentState JumpState.Apex) gravity * apexGravityMultiplier; verticalVelocity - gravity * Time.deltaTime; Vector3 move new Vector3(horizontalMove.x, verticalVelocity, horizontalMove.z); controller.Move(move * Time.deltaTime);水平移动用另一个脚本或者同一脚本处理注意水平速度不要被垂直速度影响。CharacterController的Move方法会自动处理碰撞所以不需要额外做地面检测但isGrounded的更新有一帧延迟这就是为什么要结合速度判断。5. 常见问题与排查技巧实录5.1 跳跃高度不稳定有时高有时低最常见的原因是重力应用在了FixedUpdate里而输入检测在Update里两者频率不一致导致积分误差。解决办法是把所有跳跃逻辑统一放在Update里用Time.deltaTime做积分。CharacterController本身不依赖物理引擎的固定步长所以放Update里没问题。另一个原因是可变跳跃高度的衰减系数在起跳当帧就生效了。如果玩家按下的那一帧同时也检测到了松开比如用GetButtonDown和GetButtonUp在同一帧就会立即衰减。解决办法是起跳后至少等待一帧再检测松键。5.2 角色在斜坡上跳跃时会被弹飞CharacterController在斜坡上时isGrounded为真但控制器的底部法线是斜的。如果直接给垂直速度角色会沿着斜坡法线方向被弹出去。解决办法是在起跳时把垂直速度投影到斜坡法线上或者简单粗暴地在斜坡上禁用跳跃。我一般用前者投影公式是velocity Vector3.ProjectOnPlane(velocity, groundNormal)但这样会损失一部分跳跃高度需要补偿。5.3 落地时角色抖动或穿透地面抖动通常是因为Skin Width太小控制器底部和地面反复碰撞。把Skin Width调到0.1左右试试。穿透地面是因为下落速度太快一帧内移动距离超过了控制器半径。解决办法是限制最大下落速度或者用射线检测提前把角色拉回地面。5.4 土狼时间导致空中二段跳如果土狼时间在跳跃后没有清零玩家可以在起跳后立刻再次触发跳跃。解决办法是起跳时把coyoteTimer设为0并且只有在“自然离开地面”时才重置土狼时间。判断自然离开的方法是上一帧isGrounded为真这一帧为假且垂直速度小于等于零。5.5 常见问题速查表问题现象可能原因解决方法跳跃没反应输入缓冲窗口太短增大jumpBufferTime到0.15跳跃太高初速度计算错误检查v0 2h/t_up滞空太久上升重力太小增大gUp或减小t_up落地太软下落重力太小增大gDown或减小t_down顶点卡顿顶点阈值太大减小apexThreshold斜坡弹飞垂直速度未投影用ProjectOnPlane处理落地抖动Skin Width太小调到0.1左右空中二段跳土狼时间未清零起跳时清零coyoteTimer5.6 调参心得从“能跳”到“好跳”调参的顺序很重要。我习惯先固定上升时间和下落时间调跳跃高度到满意为止然后调重力倍率让上升和下落的手感有区别最后微调顶点阈值和衰减系数让轻点和长按的区别明显但不突兀。整个过程大概需要半小时到一小时取决于你对“手感”的敏感度。有一个小技巧把跳跃高度、上升时间、下落时间做成三个滑块在运行时实时调整边玩边调。Unity的Inspector支持运行时修改公开字段利用这一点可以大幅缩短调参周期。调好后把值记下来写回代码默认值。6. 进阶扩展让跳跃系统支撑更多玩法6.1 二段跳的实现思路二段跳的本质是给角色一个额外的跳跃次数。在状态机里增加一个airJumpCount变量着地时重置为最大次数空中起跳时递减。注意二段跳不应该受土狼时间影响也不应该触发输入缓冲否则会出现“落地前按跳落地后自动二段跳”的混乱。二段跳的初速度通常比一段跳小大概是0.8倍。重力曲线也可以不同二段跳的上升重力可以更大让角色更快到达顶点显得更“轻巧”。这些都可以做成独立参数。6.2 蹬墙跳与墙面检测蹬墙跳需要在角色侧面做射线检测判断是否贴着墙。检测到墙面后允许角色在墙上再次起跳起跳方向是墙面法线方向加上向上的速度。墙面检测的射线长度略大于控制器半径检测层要排除角色自身。蹬墙跳的难点在于墙面法线的获取和起跳方向的合成。如果墙面是斜的起跳方向也要相应倾斜。另外蹬墙后应该给一个短暂的“墙面锁定”时间防止角色立刻又贴回墙上。6.3 空中冲刺与速度衰减空中冲刺是在跳跃过程中给角色一个水平方向的爆发速度然后逐渐衰减。实现方式是设置一个冲刺速度和一个衰减系数每帧把水平速度乘以衰减系数。冲刺期间禁用重力或者减小重力让角色有一段“悬停”感。冲刺的方向可以是八向也可以是角色朝向。八向冲刺需要处理对角线速度归一化否则斜向冲刺会比正向快。归一化公式是direction.Normalize()但要注意零向量的情况。6.4 跳跃与动画的同步跳跃状态机应该驱动动画状态机。Grounded对应待机或跑步Rising对应起跳Apex对应顶点Falling对应下落。切换时机要跟物理状态一致否则会出现“动画还在上升角色已经下落”的割裂感。落地动画的触发要提前几帧用射线预判落地。这样落地音效和粒子效果可以和角色实际接触地面同步而不是等isGrounded更新后才播放。预判射线的长度根据当前下落速度动态调整速度越快射线越长。7. 我个人在实际项目中的几点体会这套跳跃系统我在几个不同类型的项目里都用过从2D平台跳跃到3D动作游戏核心逻辑几乎没变只是参数和扩展不同。最大的体会是不要试图一次调到位。手感是迭代出来的先让跳跃能跑通再花时间微调参数最后加扩展机制。顺序反了会陷入“参数互相打架”的泥潭。另一个体会是把参数暴露出来比写死强。哪怕你觉得自己调好了策划或者玩家可能想要不同的手感。把跳跃高度、上升时间、下落时间、土狼时间、输入缓冲时间都做成公开字段配合一个简单的运行时调参面板能省下大量来回改代码的时间。最后分享一个小技巧在Scene视图里画一条跳跃轨迹的Gizmo把每一帧的位置连起来。这样你能直观看到跳跃的弧线是否对称、顶点是否平滑、下落是否干脆。调参时盯着这条线比盯着数字有效得多。轨迹的采样点用List保存OnDrawGizmos里遍历绘制颜色根据状态区分上升绿色、顶点黄色、下落红色一眼就能看出问题在哪。