1. 核心能力速览
看到“跳跳男+车祸=超级跳跳男”这个标题,先别急着笑。剥掉梗的外壳,它其实精准命中了游戏开发里两个最难调、最容易翻车的模块:角色跳跃手感和碰撞响应。一个篮球小游戏里,玩家按一下空格,角色起跳高度不对、起跳后飘在半空、落地像踩在棉花上,手感立刻崩;而一次“车祸”式的剧烈碰撞如果没有正确的冲量计算和状态切换,角色就会穿墙、卡进地板、或者被弹到地图外。这篇文章不讲某个具体开源项目,而是把这两个技术点拆开,从参数设计、代码实现、批量验证到排错清单,完整走一遍。
先说结论式的核心特点,方便你判断要不要往下读:
- 跳跃不是一条重力曲线。可用手感至少需要起跳冲量、上升重力缩放、顶点悬停、下落加重四段分离控制,单靠
Rigidbody.AddForce一个数值是调不出来的。 - 二段跳不是把跳跃调用两次。空中跳跃要处理冲量衰减、次数限制、落地重置、动画打断,否则会出现无限跳、跳跃穿模、落地翻滚卡死。
- “车祸”是碰撞响应的通俗说法。真正要算的是相对速度、接触法线、质量比和判定阈值,而不是简单写一个
OnCollisionEnter就完事。 - 手感三件套缺一不可:输入缓冲(jump buffer)、土狼时间(coyote time)、落地锁定(landing lock)。少任何一个,玩家都会觉得“我明明按了”。
- 参数必须能批量扫描。靠手动拖 Inspector 调手感,一天调不出三组有效数据;用脚本跑参数网格,半小时能出一张对比表。
本文演示的实操内容覆盖:环境准备与物理步长设置、跳跃控制器完整代码、二段跳实现、碰撞冲量计算与伤害判定、动画状态机参数联动、基于配置文件的参数批量验证脚本、性能观察方法、以及一份常见问题排查表。适合正在做平台跳跃、篮球、跑酷类原型开发的 Unity 使用者,也适合想理解“手感到底由什么构成”的独立开发者。全文代码基于 Unity + C#,物理公式和参数思路可以平移到 Godot、Unreal 或者自己写的 2D 引擎里。
2. 适用场景与使用边界
这个主题的适用范围比想象中宽。任何需要“角色在地面上反复起跳、落地、被撞击”的玩法都绕不开这套东西:平台跳跃、跑酷、篮球对抗、格斗游戏的击飞、甚至开放世界里的攀爬落地。核心逻辑是通用的,区别只在于参数偏好——卡通风格要夸张的滞空和弹性,拟真体育游戏要更短的滞空和更硬的落地反馈。
但有几条边界必须提前说清楚,否则容易走弯路。
第一,卡通手感和拟真物理是两条路。如果你追求“超级跳跳男”那种夸张的二段跳、空中变向、踩人弹起,就不要指望 PhysX 的默认重力给你答案。你需要主动改写重力曲线、关闭部分物理惯性、甚至在顶点做短暂的速度钳制。反过来,如果你要做拟真篮球,就不要给角色加空中二段跳,那会直接破坏物理可信度。这两套参数不通用,不要混着调。
第二,不要用真实球员、真实球队标识、真实转播画面做素材。这是最容易被忽略的合规问题。角色外观、队服配色、队徽、球场贴图,全部建议自创或使用明确可商用的资源。名字也不要直接使用真实运动员姓名。技术验证阶段用胶囊体加色块就够了,等手感定下来再换皮。
第三,碰撞响应的“伤害”逻辑要和物理分离。物理只负责让两个物体不重叠、给出正确的接触点和法线;伤害、眩晕、倒地、扣血这些属于游戏逻辑层,应该由物理事件触发、由状态机处理。把伤害计算写进OnCollisionEnter里是新手最常见的架构错误,后期加护盾、加无敌帧、加受击硬直时会改到崩溃。
第四,联机和回放场景要慎用物理驱动的位置。如果你的游戏需要帧同步或者录像回放,角色位置最好由确定性运动学计算,物理只做检测不做驱动。PhysX 在不同平台、不同 CPU 上不保证完全一致的浮点结果,这一点在做多人对战时要提前决策。
第五,调试工具要一开始就建。跳跃手感是“看不见的曲线”,没有可视化调试,你只能靠反复试玩猜参数。后面章节会给出一套最小调试方案,包括轨迹绘制和关键帧打点,投入产出比极高。
3. 环境准备与前置条件
整体硬件门槛很低,这类项目不吃显卡,普通开发机完全够用。真正影响结果的不是 GPU,而是物理步长和渲染帧率的解耦设置。
推荐环境如下:
| 项目 | 建议配置 | 说明 |
|---|---|---|
| 引擎 | Unity 2021 LTS 及以上 | 2022/6000 也可,物理 API 基本一致 |
| 脚本运行时 | .NET Standard 2.1 | 默认即可 |
| 渲染管线 | Built-in 或 URP 均可 | 本项目不依赖高级渲染特性 |
| 物理引擎 | PhysX(默认) | 2D 场景可换 Physics2D |
| 输入系统 | 旧 Input Manager 或新 Input System | 新输入系统更适合做输入缓冲 |
| 调试工具 | Unity Profiler + Physics Debugger | Physics Debugger 必开 |
| 硬件 | 任意近五年内的 CPU + 集显 | 无独显要求 |
| 磁盘 | 2GB 以上 | 主要是引擎缓存 |
关键的工程设置有三处,务必先改,否则后面所有参数都白调。
第一处是固定时间步长。默认Fixed Timestep是 0.02(50Hz),对跳跃手感来说太粗糙,角色在空中会出现明显的“台阶感”。建议改成 0.0166667(60Hz)或者直接 0.01(100Hz),代价是物理计算量上升,但对这种小场景完全无压力。
Edit -> Project Settings -> Time Fixed Timestep: 0.0166667 Maximum Allowed Timestep: 0.1第二处是刚体插值。角色刚体必须开启Interpolate,否则物理更新频率和渲染频率不一致时,角色会抖动。同时建议把Collision Detection设为Continuous,防止高速下落时穿过地面。
第三处是自动同步变换。Physics.autoSyncTransforms默认关闭是正确的,不要为了图省事打开,它会在每次读取 Transform 时强制同步物理状态,性能开销明显,而且会掩盖你代码里更新顺序的错误。
// 初始化阶段的全局设置,放在启动脚本里 using UnityEngine; public class PhysicsBootstrap : MonoBehaviour { void Awake() { // 物理以 100Hz 推进,渲染仍按显示器刷新率 Time.fixedDeltaTime = 0.01f; // 保持默认关闭,避免隐式同步带来的性能损耗 Physics.autoSyncTransforms = false; } }前置检查清单建议逐条确认:物理层碰撞矩阵是否配置(角色层和地面层要能碰、和触发区要分开)、角色碰撞体是否是胶囊体而不是盒子(胶囊体在斜面和小台阶上的表现远好于 BoxCollider)、Rigidbody的Freeze Rotation是否勾选(角色不该被物理转飞)、地面碰撞体是否用了 MeshCollider(能换 BoxCollider 就换,性能和稳定性都更好)。这四项没做好,后面调参就是在一个错误的基线上做无用功。
4. 跳跃物理核心实现与二段跳
跳跃手感的本质是速度曲线的分段设计。如果只用一次冲量加恒定重力,得到的是一个完美抛物线,物理正确但手感极差,因为真实的人类运动不是抛物线:起跳瞬间爆发力强,接近顶点时减速明显,下落阶段受重力加速更快。
工程上通常把它拆成四段来控制:
- 起跳段:给一个向上的初速度,同时立刻把水平速度调整为玩家输入方向,保证“跳出去”的方向感。
- 上升段:重力乘以一个小于 1 的系数(比如 0.8),让角色上升更“轻”,滞空时间变长。
- 顶点段:当竖直速度的绝对值小于某个阈值时,短暂把重力进一步压低,制造一个“悬停感”。这个悬停是卡通手感的核心。
- 下落段:重力乘以一个大于 1 的系数(比如 1.8 到 2.5),让落地干脆利落,不拖泥带水。
配合这套曲线,还需要三个“容错”机制。土狼时间指角色离开平台边缘后的短时间内(一般 0.1 到 0.15 秒)仍然允许起跳,解决“我明明还在平台上”的心理落差。输入缓冲指玩家在落地前按下跳跃键,系统记录下来,落地瞬间自动执行,解决“我明明按了却没跳”的挫败感。可变跳跃高度指玩家松开跳跃键时,如果角色仍在上升,就把竖直速度按比例削减,实现“短按小跳、长按大跳”。
下面是完整的跳跃控制器实现,包含了上述全部机制。
using UnityEngine; [RequireComponent(typeof(Rigidbody))] [RequireComponent(typeof(CapsuleCollider))] public class JumpController : MonoBehaviour { [Header("跳跃基础参数")] public float jumpVelocity = 8f; // 起跳初速度 public float maxJumpCount = 2; // 最大跳跃次数,2 表示二段跳 public float airJumpScale = 0.85f; // 空中跳跃冲量衰减 [Header("重力曲线")] public float riseGravityScale = 0.8f; // 上升段重力倍率 public float fallGravityScale = 2.2f; // 下落段重力倍率 public float apexThreshold = 1.5f; // 顶点判定速度阈值 public float apexGravityScale = 0.5f; // 顶点重力倍率 [Header("容错窗口")] public float coyoteTime = 0.12f; // 土狼时间 public float jumpBufferTime = 0.12f; // 输入缓冲 public float jumpCutFactor = 0.5f; // 松键削减系数 private Rigidbody rb; private CapsuleCollider capsule; private int jumpUsed; private float coyoteCounter; private float bufferCounter; private bool isGrounded; private bool jumpReleased = true; void Awake() { rb = GetComponent<Rigidbody>(); capsule = GetComponent<CapsuleCollider>(); rb.freezeRotation = true; rb.interpolation = RigidbodyInterpolation.Interpolate; rb.collisionDetectionMode = CollisionDetectionMode.Continuous; } void Update() { // 输入采集放在 Update,物理执行放在 FixedUpdate if (Input.GetButtonDown("Jump")) { bufferCounter = jumpBufferTime; jumpReleased = false; } if (Input.GetButtonUp("Jump")) { jumpReleased = true; } if (bufferCounter > 0f) bufferCounter -= Time.deltaTime; if (coyoteCounter > 0f) coyoteCounter -= Time.deltaTime; } void FixedUpdate() { CheckGrounded(); ApplyGravityCurve(); TryJump(); ApplyJumpCut(); } void CheckGrounded() { // 用射线而不是仅靠碰撞事件,避免斜坡和台阶误判 float radius = capsule.radius * 0.95f; Vector3 origin = transform.position + capsule.center + Vector3.down * (capsule.height * 0.5f - radius); bool ground = Physics.SphereCast(origin, radius, Vector3.down, out _, 0.08f, LayerMask.GetMask("Ground"), QueryTriggerInteraction.Ignore); if (ground && !isGrounded) { jumpUsed = 0; coyoteCounter = coyoteTime; } if (!ground && isGrounded) { coyoteCounter = coyoteTime; } isGrounded = ground; } void ApplyGravityCurve() { float vy = rb.velocity.y; float scale = vy < 0f ? fallGravityScale : riseGravityScale; if (Mathf.Abs(vy) < apexThreshold && !isGrounded) { scale = apexGravityScale; } // 用额外加速度实现曲线,基础重力由 Physics.gravity 提供 Vector3 extra = Vector3.down * (Physics.gravity.magnitude * (scale - 1f)); rb.AddForce(extra, ForceMode.Acceleration); } void TryJump() { if (bufferCounter <= 0f) return; bool canGroundJump = isGrounded || coyoteCounter > 0f; bool canAirJump = !canGroundJump && jumpUsed < maxJumpCount; if (!canGroundJump && !canAirJump) return; float v = canGroundJump ? jumpVelocity : jumpVelocity * airJumpScale; Vector3 vel = rb.velocity; vel.y = v; rb.velocity = vel; jumpUsed = canGroundJump ? 1 : jumpUsed + 1; bufferCounter = 0f; coyoteCounter = 0f; } void ApplyJumpCut() { if (!jumpReleased || isGrounded) return; if (rb.velocity.y > 0f) { Vector3 vel = rb.velocity; vel.y *= jumpCutFactor; rb.velocity = vel; } } }这段代码里有几个细节值得单独说。CheckGrounded用SphereCast而不是OnCollisionStay,原因是碰撞事件在角色贴墙、站在斜坡边缘、或者被其他刚体挤压时会误报,导致角色在墙边可以无限跳。用向下球体检测配合一个独立的Ground层,判断稳定得多。jumpUsed的计数逻辑要区分地面跳和空中跳,地面跳记为 1,空中跳在此基础上累加,而不是每次跳跃都从 0 开始,否则二段跳会变成三段跳。
ApplyGravityCurve用的是增量叠加而不是直接改rb.velocity.y。这一点很重要:如果直接乘算速度,会和物理引擎自身的重力积分冲突,导致不同帧率下手感不一致。用ForceMode.Acceleration叠加额外加速度,物理引擎负责积分,结果更稳定。
参数表参考(卡通风格,可以按项目调整):
| 参数 | 卡通手感 | 拟真体育 | 影响 |
|---|---|---|---|
| jumpVelocity | 8 - 12 | 5 - 7 | 起跳高度 |
| riseGravityScale | 0.7 - 0.9 | 1.0 | 滞空时间 |
| fallGravityScale | 2.0 - 3.0 | 1.3 - 1.6 | 落地干脆度 |
| apexGravityScale | 0.4 - 0.6 | 1.0 | 顶点悬停感 |
| coyoteTime | 0.1 - 0.15 | 0.05 | 边缘容错 |
| jumpBufferTime | 0.1 - 0.15 | 0.08 | 连跳顺畅度 |
| airJumpScale | 0.8 - 0.9 | 不适用 | 二段跳力度衰减 |
5. 碰撞响应与撞击判定
“车祸”这个词用得很准,因为高强度碰撞最难处理的不是“碰上了”,而是“碰上之后怎么反应”。一个 20 米每秒的自由落体撞上地面,和一个缓慢走上斜坡,在物理引擎眼里都是接触事件,但游戏逻辑上完全是两回事。
所以碰撞系统的设计原则是:物理层只负责不穿模,逻辑层负责分级响应。分级依据是相对速度、接触法线方向和物体质量。
先看相对速度怎么算。碰撞瞬间,用当前速度减去上一个物理帧记录的速度,得到加速度近似值,再乘以质量得到冲量。这个值比单纯看速度更准确,因为它反映了“突然停止”的程度。
using UnityEngine; public class ImpactHandler : MonoBehaviour { [Header("判定阈值")] public float lightImpactSpeed = 4f; // 轻撞 public float heavyImpactSpeed = 9f; // 重撞 public float lethalImpactSpeed = 15f; // 致命撞击 [Header("受击表现")] public float stunDurationLight = 0.2f; public float stunDurationHeavy = 0.8f; private Rigidbody rb; private Vector3 lastVelocity; private float lastImpactTime; void Awake() { rb = GetComponent<Rigidbody>(); } void FixedUpdate() { lastVelocity = rb.velocity; } void OnCollisionEnter(Collision collision) { // 防止同一次接触重复触发 if (Time.time - lastImpactTime < 0.1f) return; lastImpactTime = Time.time; Vector3 relVel = lastVelocity - collision.relativeVelocity; float impactSpeed = relVel.magnitude; // 只有法线方向接近竖直向上的撞击才判定为落地 Vector3 normal = collision.GetContact(0).normal; if (impactSpeed >= lethalImpactSpeed) { OnLethalImpact(impactSpeed, normal); } else if (impactSpeed >= heavyImpactSpeed) { OnHeavyImpact(impactSpeed, normal); } else if (impactSpeed >= lightImpactSpeed) { OnLightImpact(impactSpeed, normal); } } void OnLightImpact(float speed, Vector3 normal) { // 轻微震动,不打断操作 Debug.Log($"[Impact] light {speed:F1}, normal {normal}"); } void OnHeavyImpact(float speed, Vector3 normal) { // 进入硬直状态,动画播放受击 GetComponent<PlayerStateMachine>()?.EnterStun(stunDurationHeavy); } void OnLethalImpact(float speed, Vector3 normal) { // 触发倒地或出界流程 GetComponent<PlayerStateMachine>()?.EnterDown(); } }这段代码的核心在collision.relativeVelocity。它返回的是两个刚体碰撞瞬间的相对速度,比用lastVelocity简单相减更准,因为后者在高速运动下误差明显。实际项目里可以把两种方式都算一遍,做双保险,取较大值作为最终冲量。另外要注意collision.GetContact(0)在多接触点情况下只取第一个,如果角色是多点着地,更严的做法是遍历所有接触点、取法线 y 分量最大的那个。
碰撞穿模是另一个高频问题,尤其在“超级跳跳男”这种高速下落的场景。防护手段有三层:
第一,把角色的Collision Detection设为Continuous,地面设为Continuous Speculative。前者对所有动态刚体做连续检测,后者对静态碰撞体做推测性检测,组合起来能挡住绝大多数穿透。
第二,避免角色自己碰撞自己。角色内部的骨骼、武器挂点如果挂了碰撞体,容易互相挤压产生诡异位移,统一放到一个不参与角色间碰撞的层里。
第三,给地面加一层薄的“兜底平面”。即使主碰撞体失效,角色也会被兜底平面接住,不至于掉出地图。这一层可以做得比主地面低 0.5 米,玩家看不见。
| 碰撞场景 | 判定依据 | 推荐处理 |
|---|---|---|
| 正常落地 | 法线 y > 0.7,速度 < 4 | 播放落地缓冲动画 |
| 高处落地 | 法线 y > 0.7,速度 4 - 9 | 落地硬直 + 相机震动 |
| 高速坠落 | 法线 y > 0.7,速度 > 15 | 倒地状态 + 输入屏蔽 |
| 侧面撞墙 | 法线 y < 0.3 | 速度衰减,不判定伤害 |
| 被其他角色撞飞 | 相对速度 > 8,对方质量更大 | 击飞状态 + 失控时间 |
| 贴墙滑动 | 持续接触,速度低 | 允许沿墙滑落,禁止墙面跳 |
6. 动画状态机与手感联动
物理参数调好了,如果动画跟不上,手感依然是碎的。最典型的问题是“角色已经落地了,动画还在空中姿态”,或者“二段跳触发了,但动画没播”。解决办法是把状态机作为唯一的真相来源,物理只负责计算,状态机负责决定播什么、能不能操作。
状态机的关键参数不宜过多,四个足够:Grounded(是否在地面)、VerticalSpeed(竖直速度,用于混合上升下降动画)、JumpCount(当前跳跃次数)、IsStunned(是否受击硬直)。转移规则写死在代码里,不要依赖 Animator 的 Transition 条件互相触发,容易产生竞态。
using UnityEngine; public class PlayerStateMachine : MonoBehaviour { private Animator anim; private Rigidbody rb; private float stunTimer; public bool CanControl { get; private set; } = true; void Awake() { anim = GetComponent<Animator>(); rb = GetComponent<Rigidbody>(); } void Update() { if (stunTimer > 0f) { stunTimer -= Time.deltaTime; if (stunTimer <= 0f) ExitStun(); } anim.SetFloat("VerticalSpeed", rb.velocity.y); anim.SetBool("IsStunned", stunTimer > 0f); } public void EnterStun(float duration) { stunTimer = duration; CanControl = false; anim.SetTrigger("Hit"); } void ExitStun() { CanControl = true; } public void EnterDown() { stunTimer = 999f; CanControl = false; anim.SetTrigger("Down"); } }落地动画的关键是过渡时间。从空中状态切到落地状态,混合时间设为 0.05 到 0.08 秒,太短会有硬切感,太长会显得拖沓。同时落地动画的前几帧可以带一个位移下沉,让角色的视觉重心短暂降低,这个细节对手感提升非常明显,成本却几乎为零。
另外强烈建议加一套动画事件打点。在起跳帧、最高点帧、落地帧分别挂一个事件,把时间戳打印出来,和物理状态日志对比。如果发现动画总是比物理慢 2 到 3 帧,那就是混合时间或者 Animator 更新顺序的问题。把 Animator 的Update Mode从Normal改成Animate Physics,可以让动画和物理帧对齐,代价是响应略慢一帧,需要权衡。
6.1 输入与操作的耦合
还有一点常被忽略:受击硬直期间必须屏蔽跳跃输入,但不能屏蔽输入缓冲。也就是说,玩家在硬直中按了跳跃,这个输入要记录下来,硬直结束的瞬间如果还在地面,就立刻执行。否则玩家会觉得“我一落地就被打,起来想跑跑不掉”。缓冲时间可以设得短一点,0.08 秒左右,既能接住操作,又不会让玩家蒙混过关。
7. 参数配置与批量验证
手感调参最忌讳的就是“改一个数字,运行一次,感觉一下”。这套流程一天调不了几组参数,而且凭主观感受很难判断哪一组更好。更工程化的做法是把参数外置成配置,用脚本批量跑,输出可对比的数据表。
第一步,把参数抽到一个 JSON 文件里,方便脚本批量替换。
{ "presets": [ { "name": "cartoon_high", "jumpVelocity": 11.0, "riseGravityScale": 0.75, "fallGravityScale": 2.6, "apexGravityScale": 0.45, "coyoteTime": 0.14, "maxJumpCount": 2, "airJumpScale": 0.85 }, { "name": "realistic_mid", "jumpVelocity": 6.2, "riseGravityScale": 1.0, "fallGravityScale": 1.5, "apexGravityScale": 1.0, "coyoteTime": 0.06, "maxJumpCount": 1, "airJumpScale": 1.0 } ] }第二步,用 Python 复现同一套物理积分公式,在本地跑参数网格。因为跳跃本质上是几百毫秒的运动,完全可以在 CPU 上瞬间模拟上万次,得到起跳高度、滞空时间、落地速度这些硬指标,先用数值筛掉明显不合理的组合,再去引擎里做主观确认。
import json import itertools import csv G = 9.81 DT = 0.01 MAX_T = 3.0 def simulate(v0, rise_scale, fall_scale, apex_scale, apex_thr=1.5, air_jump=None, air_scale=0.85): y, v = 0.0, v0 t = 0.0 max_h = 0.0 used_air = False land_t = None while t < MAX_T: if v < 0: scale = fall_scale elif abs(v) < apex_thr: scale = apex_scale else: scale = rise_scale v -= G * scale * DT y += v * DT t += DT max_h = max(max_h, y) # 空中跳跃:在接近顶点时触发 if air_jump and not used_air and v < 0.5: v = air_jump * air_scale used_air = True if y <= 0 and t > 0.05: land_t = t break return max_h, land_t, abs(v) rows = [] grid = itertools.product( [8.0, 9.0, 10.0, 11.0, 12.0], [0.7, 0.8, 0.9, 1.0], [1.5, 2.0, 2.5, 3.0], [0.4, 0.5, 0.6, 1.0], ) for v0, rise, fall, apex in grid: h, t_land, v_land = simulate(v0, rise, fall, apex) rows.append({ "jump_velocity": v0, "rise_scale": rise, "fall_scale": fall, "apex_scale": apex, "max_height": round(h, 3), "air_time": round(t_land or 0, 3), "land_speed": round(v_land, 3), }) with open("jump_grid.csv", "w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows) print(f"共生成 {len(rows)} 组参数组合")跑完之后你会得到几百组数据,按air_time和land_speed两个维度筛:滞空时间在 0.6 到 0.9 秒之间、落地速度在 8 到 12 之间的组合,是卡通手感比较舒服的区间。先用数值筛到 20 组,再进引擎里逐个试,效率比盲调高十倍以上。
第三步,如果要做自动化回归,可以用 Unity 的批处理模式跑固定输入序列的测试场景,输出轨迹日志。
# 需要按实际项目路径和测试脚本名调整 Unity \ -batchmode \ -nographics \ -projectPath "/path/to/your_project" \ -executeMethod TestRunner.RunJumpRegression \ -logFile - \ -quit本主题不涉及网络接口 API,如果你的项目需要把参数下发给服务端做远程配置,可以在这个 JSON 结构上直接扩展一个GET /api/presets的读取逻辑,但手感和服务端无关,不建议把手感参数做成动态下发,会导致不同客户端表现不一致,破坏公平性。
8. 资源占用与性能观察
这类项目的性能瓶颈几乎从不在 GPU,而在物理计算和 GC 分配。观察方法很直接:打开 Unity Profiler,看Physics.Processing、Physics.LateUpdate和GC.Alloc三条曲线。
Fixed Timestep从 0.02 降到 0.01,物理计算量大约翻倍。在一个只有几十个刚体的小场景里,这个开销通常在 0.5 毫秒以内,完全可接受。但如果场景里有几百个道具、球、可破坏物,就要做取舍:角色用 0.01,道具用插值补偿,不要全场景统一提高频率。
GC.Alloc是另一个隐藏杀手。上面代码里Physics.SphereCast如果没有加out参数,会返回一个新数组,每次调用都产生垃圾。同理,GetContact(0)在某些情况下会分配内存。做法是在Awake里预分配一个RaycastHit[]缓冲,用Physics.SphereCastNonAlloc替代SphereCast,把每帧分配压到 0。
降低碰撞检测开销还有几个常规手段:把静态地面合并成少量大碰撞体,而不是几百个小瓦片;给远处不参与玩法的物体关掉物理;用层碰撞矩阵把不相关的组合直接屏蔽,这比在代码里判断if (other.tag == ...)高效得多。
需要重点关注的指标是帧率稳定性而不是峰值帧率。跳跃手感对卡顿极其敏感,一次 30 毫秒的卡顿会让玩家在起跳瞬间感到明显延迟。建议在开发阶段把Application.targetFrameRate固定下来,关掉垂直同步做测试,确认在最差的场景下物理帧也能稳定推进。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 角色在墙边能无限跳 | 着地检测用了碰撞事件,贴墙误判 | 打印isGrounded,站在墙边观察 | 改用向下 SphereCast + 独立 Ground 层 |
| 二段跳变成三段跳 | jumpUsed每次跳跃重置 | 打印跳跃次数日志 | 地面跳记 1,空中跳累加,落地归零 |
| 落地瞬间穿插进地面 | Collision Detection 为 Discrete | Physics Debugger 看接触点 | 改 Continuous,地面加兜底平面 |
| 高空下落穿过地板 | 速度过快,单帧位移大于碰撞体厚度 | 记录每帧位移量 | 提高物理频率或加厚地面碰撞体 |
| 松键后角色还在上升很久 | 未实现跳跃削减 | 观察松键后速度变化 | 加jumpCutFactor,上升段按比例削减 |
| 空中可以随意变向 | 水平速度在空中被完全覆盖 | 打印水平速度 | 空中输入乘以 0.3 - 0.6 的系数 |
| 动画比物理慢半拍 | Animator 更新模式不匹配 | 对比打点时间戳 | 改用 Animate Physics 或缩短混合时间 |
| 受击后无法立即起跳 | 硬直期间丢弃了输入 | 加输入日志 | 硬直中记录缓冲,结束后立即执行 |
| 相机抖动严重 | 相机跟随放在 Update 且无平滑 | 观察抖动频率 | 相机跟随放 LateUpdate,加阻尼 |
| 参数改了没效果 | 脚本参数被预制体覆盖 | 检查 Inspector 实际值 | 统一参数来源,避免多份配置 |
排错的核心思路是先定位层,再定位参数。手感问题通常分三层:输入层(按键有没有被识别)、物理层(速度曲线对不对)、表现层(动画和相机跟不跟得上)。加日志的时候按这三层分别打点,打印Time.time、按键状态、速度、位置、动画状态,一比就能看出问题出在哪一层。最怕的是不分层,看到“跳不起来”就去改重力参数,结果真正的原因是输入被 UI 拦截了。
10. 最佳实践与使用建议
第一,先做最小可玩原型,再谈美术。胶囊体加一个方块地面,把跳跃、二段跳、落地、撞击四条链路跑通,全程不超过两百行代码。这一步没走通就去做角色模型和动画,纯属浪费时间,因为手感一旦大改,动画全都要重做。
第二,把参数集中到一个 ScriptableObject 或 JSON 里,不要散落在各个预制体上。手感参数是项目级的核心资产,散落之后会出现“这个场景的角色跳得比那个场景高”的诡异问题,而且没法做版本对比。
第三,建立一套固定的测试用例。比如:原地起跳高度是否一致、连续两次跳跃的间隔是否稳定、从两米高台落下是否触发硬直、贴着墙跳是否能上去、快速连按五次跳跃键是否出现异常。这五个用例每次改动物理参数都跑一遍,能挡住八成回归问题。
第四,输入的采集和执行必须分帧。输入在Update里记录,物理在FixedUpdate里执行,中间的缓冲窗口用计数器维护。混在一起写在Update里直接改速度,会出现帧率越高跳得越高的问题,这类 bug 极难排查。
第五,所有涉及角色形象、队服、球场、音效的素材,确认授权再用。技术验证阶段就用纯色几何体,等玩法确定、需要对外展示时,再统一替换成自研或明确可商用的资源。涉及真人肖像、真实赛事画面的内容,即使只是内部测试,也建议提前明确使用边界。
第六,上线前做一次低帧率压力测试。把帧率人为压到 30 帧,重新走一遍所有跳跃场景。很多手感问题在 60 帧下完全看不出来,一到低端机就暴露,尤其是输入缓冲和土狼时间的计时如果用Time.deltaTime累加,低帧率下窗口会明显变短。
如果需要继续往下扩展,优先级建议是:先加斜坡与台阶的着地适配(SphereCast 的半径和偏移要跟着胶囊体参数走),再加击飞与受击方向(把碰撞法线和相对速度组合成击飞向量),最后才考虑网络同步。前两步做完,一个手感扎实的篮球跳跃原型就成立了,剩下的都是内容填充。参数表建议留在仓库里当作文档,下次开新项目直接复用,能省掉至少两天的重复调参。