Unity物理系统实战:从碰撞检测到爆炸效果的完整实现与优化
1. 项目概述:当物理遇上游戏,从碰撞到爆炸的实战之旅
如果你正在用Unity开发游戏,尤其是动作、射击、赛车或者任何需要物体交互的类型,那么物理系统绝对是你绕不开的核心。很多新手朋友可能会觉得Unity的物理系统是个“黑盒”,把刚体(Rigidbody)和碰撞器(Collider)一挂,物体就能掉下来、能撞开,似乎就够用了。但当你真正想实现一个“有分量”的碰撞反馈,或者一个看起来“很带感”的爆炸效果时,往往会发现事情没那么简单——要么物理表现轻飘飘像纸片,要么爆炸效果假得一眼就能看出来,性能还可能瞬间崩掉。
我自己在项目里就踩过不少坑。比如,早期做一个射击游戏,子弹打中墙壁,期望的是火花四溅并留下弹痕,结果要么没反应,要么整个墙体莫名其妙被炸飞。又比如,实现一个爆炸桶,玩家射击后,它应该先剧烈膨胀再碎裂,冲击波推开周围的杂物。如果只用简单的Instantiate一个预制体然后AddForce,效果会非常生硬,缺乏那种真实的、分阶段的物理破坏感。
所以,今天我想结合一个完整的实战案例,带你深入Unity物理系统的腹地。我们不止要搞懂OnCollisionEnter和OnTriggerEnter的区别,更要弄明白如何利用物理材质(Physic Material)、关节(Joints)、射线(Raycast)甚至刚体插值(Interpolation)来提升手感。最终的目标,是实现一个从精确碰撞检测开始,到引发一个包含冲击波、碎片飞溅、受力衰减、屏幕震动等元素的复合型爆炸效果。这个过程会涉及到物理引擎的参数调优、性能考量以及一些“教科书”上不会写的骚操作。无论你是刚接触Unity物理的新手,还是想优化现有效果的老手,相信这篇都能给你带来可以直接抄作业的干货。
2. 核心思路与架构设计:为什么不是简单的“挂组件”?
在动手写代码之前,我们必须先理清思路。一个健壮、高效且表现力丰富的物理交互系统,绝不是给每个物体挂上刚体和碰撞器就完事的。我们需要一个分层的、可管理的架构。
2.1 物理交互的层次化设计
我的经验是,将物理交互分为三个层次:
- 检测层:负责“有没有发生接触”。这完全是物理引擎(如PhysX)的职责,通过碰撞器形状(Box, Sphere, Mesh等)和刚体的位置、速度来计算。我们的代码通过
MonoBehaviour里的消息方法(如OnCollisionXXX)来接收结果。 - 逻辑层:负责“发生接触后该怎么办”。这是游戏逻辑的核心,我们在这里判断碰撞的双方是谁、碰撞点在哪、力度多大,然后决定扣血、播放音效、触发任务等。
- 表现层:负责“让玩家看到/听到/感觉到发生了什么”。这是爆炸效果、粒子、屏幕抖动、音效的舞台。这一层必须与逻辑层解耦,但又需要紧密配合。
很多新手会把这三层的代码全部塞进一个OnCollisionEnter方法里,导致脚本臃肿,难以调试和复用。我们的架构应该让它们各司其职。
2.2 爆炸效果的系统性思考
一个真实的爆炸效果,在物理层面是多个效应的叠加:
- 瞬时冲击:在爆炸中心瞬间产生一个极大的、向外的力(Force)或冲量(Impulse)。
- 衰减:这个力随着距离爆炸中心的距离增加而衰减。通常遵循平方反比或指数衰减规律。
- 多向性:力是沿着爆炸中心到物体中心的方向施加的,所以物体会被“推开”而不是“向上蹦”。
- 间接影响:爆炸可能引发连锁反应,比如炸塌一堵墙,墙体的碎片又成为新的伤害源。
因此,我们不能简单地遍历场景中所有刚体然后AddForce。我们需要一个系统来管理爆炸实例,计算每个受影响物体的受力,并处理可能产生的性能问题(比如大量刚体同时运动)。
2.3 工具选型与性能预考量
- 刚体类型:对于动态物体使用
Rigidbody,对于静态环境(如地面、墙壁)使用Static或Kinematic的刚体,或者干脆只用碰撞器。Kinematic刚体适合由代码完全控制的物体(如移动平台),它不会受物理力影响,但可以影响其他物体。 - 碰撞器形状:优先使用基本形状(Primitive)如Box、Sphere、Capsule。它们的计算效率最高。Mesh Collider虽然贴合模型,但性能开销巨大,应尽量避免用于频繁移动或参与复杂碰撞的物体。可以尝试用多个基本碰撞器来近似复杂形状。
- 物理材质:这是调节碰撞“手感”的关键。通过调整
Friction(摩擦力)和Bounciness(弹性),你可以让物体感觉像冰面一样滑,或者像皮球一样弹。为不同材质的物体(金属、木头、橡胶)配置不同的物理材质,能极大提升真实感。 - 射线与形状投射:除了碰撞,我们经常需要主动探测,比如判断手雷的投掷轨迹是否会被阻挡,或者爆炸的冲击波是否“看得到”某个物体。这时就需要用到
Physics.Raycast或Physics.SphereCast。
注意:在移动平台或WebGL上,物理计算是主要的性能瓶颈之一。在设计初期就要严格控制动态刚体的数量,并善用
Rigidbody的Sleep状态(当物体静止时,物理引擎会使其“休眠”以节省计算)。
3. 碰撞检测的深度解析与精细化实现
碰撞检测是物理交互的基石。Unity提供了两套机制:碰撞(Collision)和触发(Trigger)。用错场景是新手最常见的错误之一。
3.1 碰撞 vs. 触发:本质区别与选用准则
碰撞(Collision):两个物体都带有非触发器的碰撞器(Collider)和至少一个刚体(Rigidbody)。物理引擎会计算它们之间的接触力,阻止它们相互穿透,并产生一个真实的物理反馈(如反弹)。你会收到
OnCollisionEnter/Stay/Exit消息。- 适用场景:需要物理模拟的实体交互。如:小球落地、汽车撞墙、角色被怪物击中。
- 代码示例:
void OnCollisionEnter(Collision collision) { // collision.contacts 包含碰撞点信息数组 // collision.impulse 包含碰撞冲量,可用于判断碰撞强度 ContactPoint contact = collision.contacts[0]; Vector3 hitPoint = contact.point; Vector3 hitNormal = contact.normal; // 例如:在碰撞点生成一个火花特效 Instantiate(sparkEffect, hitPoint, Quaternion.LookRotation(hitNormal)); }
触发(Trigger):将碰撞器的
Is Trigger属性勾选。此时物理引擎会忽略它们的物理阻挡,但依然会检测它们的重叠。你会收到OnTriggerEnter/Stay/Exit消息。- 适用场景:需要检测区域重叠但无需物理阻挡。如:拾取物品、进入关卡触发区、技能范围检测。
- 代码示例:
void OnTriggerEnter(Collider other) { if (other.CompareTag("Pickup")) { Destroy(other.gameObject); playerScore++; } }
核心准则:如果你需要物体被“挡住”或者产生力的相互作用,用碰撞;如果只需要知道“进入了某个区域”,用触发。混淆使用会导致奇怪的物理bug,比如物体鬼畜抖动或者穿模。
3.2 碰撞信息的深度挖掘与利用
Collision对象是一个信息宝库,但很多人只用了collision.gameObject。
collision.contacts: 这是一个ContactPoint数组。一次碰撞可能发生在多个点(比如一个盒子的一角撞进缝隙)。通常取第一个点(contacts[0])作为主要碰撞点。ContactPoint.point是世界坐标的碰撞点,normal是碰撞表面的法线方向。这个法线对于生成贴合的粒子效果(如火花、弹痕)至关重要。Quaternion.LookRotation(normal)可以让你得到一个让特效“贴在”表面的旋转。collision.impulse: 本次碰撞的冲量向量。它的magnitude(大小)可以用来近似衡量碰撞的剧烈程度。你可以用它来决定播放多大的撞击声,或者是否要触发一个“重击”的特效。collision.relativeVelocity: 两个碰撞物体之间的相对速度。这对于计算碰撞伤害非常有用。比如,两辆高速相向而行的车相撞,相对速度很大,伤害就应该高。
3.3 实战技巧:优化碰撞检测性能与精度
- 层级碰撞矩阵(Layer Collision Matrix):这是Unity里被严重低估的功能。你可以在
Edit -> Project Settings -> Physics中找到它。通过给物体分配不同的Layer(如Player, Enemy, Bullet, Environment),并在这个矩阵中勾选哪些Layer之间需要检测碰撞,你可以精确控制物理计算的范围。务必禁用所有不必要的交叉检测,比如UI层和Environment层之间完全没有必要检测碰撞。这能直接减少物理引擎每帧的计算量。 - 碰撞器尺寸与形状优化:永远让碰撞器略小于视觉模型(Mesh)。这可以避免物体因为浮点精度问题看起来“还没碰到”就发生了碰撞。对于角色,使用胶囊体(Capsule Collider)通常比盒子更符合人体形状且更稳定。对于复杂的静态场景,考虑使用
Physics.BakeMesh将多个网格碰撞器合并,或者使用更简单的代理碰撞体。 - 善用
Physics.queriesHitTriggers和Physics.queriesHitBackfaces:在脚本中可以通过这些全局设置来控制射线或形状投射是否会检测到触发器,或者是否检测网格的背面。在某些特定需求下(如子弹只检测第一个实体的正面),调整它们能避免意外情况。
4. 构建一个可复用、可配置的爆炸系统
现在,我们进入最激动人心的部分:爆炸。我们将构建一个ExplosionSystem脚本,它不仅仅是一个效果播放器,更是一个物理事件发生器。
4.1 爆炸系统的核心参数设计
首先,我们定义一个ExplosionProfile脚本ableObject,用于配置不同类型的爆炸(如手雷、火箭筒、汽油桶)。
// ExplosionProfile.cs [CreateAssetMenu(fileName = "New Explosion Profile", menuName = "Physics/Explosion Profile")] public class ExplosionProfile : ScriptableObject { [Header("基础设置")] public float radius = 5f; // 爆炸半径 public float baseForce = 700f; // 基础力大小 public AnimationCurve forceFalloff = AnimationCurve.EaseInOut(0f, 1f, 1f, 0.1f); // 力随距离衰减曲线 public LayerMask affectedLayers = -1; // 影响哪些层 [Header("伤害与效果")] public float baseDamage = 50f; public AnimationCurve damageFalloff; // 伤害衰减曲线 public GameObject explosionEffectPrefab; // 视觉特效预制体 public AudioClip explosionSound; [Header("高级选项")] public float upwardModifier = 0.5f; // 向上的修正力,让物体飞起来一点 public ForceMode forceMode = ForceMode.Impulse; // 力的模式:Impulse(瞬时冲量)或Force(持续力) public bool explodeInFixedUpdate = true; // 是否在FixedUpdate中执行,保证物理稳定性 }关键点解析:
AnimationCurve用于衰减:这是实现真实感的关键。平方反比衰减(力与距离的平方成反比)可以用一条从(0,1)快速下降到(1,0)的曲线来近似。通过可视化曲线编辑器,你可以轻松调整爆炸的“柔和”或“锐利”程度。比如,汽油桶爆炸可能希望近处威力极大,但衰减很快;而冲击波手雷则希望有一个更平缓的衰减,影响范围更广。LayerMask:确保爆炸只影响你想要的物体,比如忽略UI、忽略触发器等。ForceMode:ForceMode.Impulse是添加一个瞬时冲量,适合爆炸这种瞬间事件。ForceMode.Force是添加一个持续的力,如果你在做持续性的推力场(如风扇),可以用这个。
4.2 爆炸力的精确计算与施加
接下来是ExplosionSystem的核心方法。我们使用Physics.OverlapSphere来获取爆炸半径内的所有碰撞体。
// ExplosionSystem.cs public void ExecuteExplosion(Vector3 center, ExplosionProfile profile) { // 1. 播放视觉与听觉效果 if (profile.explosionEffectPrefab != null) Instantiate(profile.explosionEffectPrefab, center, Quaternion.identity); if (profile.explosionSound != null) AudioSource.PlayClipAtPoint(profile.explosionSound, center); // 2. 物理计算与施加(建议在FixedUpdate中调用此部分) Collider[] hitColliders = Physics.OverlapSphere(center, profile.radius, profile.affectedLayers); foreach (Collider hitCollider in hitColliders) { Rigidbody rb = hitCollider.attachedRigidbody; // 如果碰撞体没有刚体,或者刚体是运动学(Kinematic)的,则跳过 if (rb == null || rb.isKinematic) continue; // 计算距离和方向 Vector3 direction = (hitCollider.transform.position - center).normalized; float distance = Vector3.Distance(hitCollider.transform.position, center); float normalizedDistance = Mathf.Clamp01(distance / profile.radius); // 归一化距离 [0,1] // 根据衰减曲线计算实际力和伤害 float forceMultiplier = profile.forceFalloff.Evaluate(normalizedDistance); float damageMultiplier = profile.damageFalloff.Evaluate(normalizedDistance); float actualForce = profile.baseForce * forceMultiplier; float actualDamage = profile.baseDamage * damageMultiplier; // 施加力(考虑向上修正) Vector3 forceVector = direction * actualForce; forceVector.y += profile.upwardModifier * actualForce; // 添加一点向上的力 rb.AddForce(forceVector, profile.forceMode); // 传递伤害(需要目标物体有如`IDamageable`的接口) IDamageable damageable = hitCollider.GetComponent<IDamageable>(); if (damageable != null) { damageable.TakeDamage(actualDamage, center); } // 3. (可选)高级处理:破碎效果 FractureObject fracture = hitCollider.GetComponent<FractureObject>(); if (fracture != null && normalizedDistance < 0.3f) // 距离很近时触发破碎 { fracture.Shatter(actualForce, center); } } // 4. (可选)屏幕震动 CameraShake.Instance?.Shake(profile.baseForce * 0.001f, 0.3f); }实操心得:
- 性能:
Physics.OverlapSphere是一个物理查询,开销不小。避免在同一帧内进行大量爆炸。对于性能敏感的场景,可以考虑使用对象池(Object Pooling)来管理爆炸效果实例,或者将非关键的视觉特效(如远端爆炸的碎片)的细节等级降低。 - 力的施加点:上面的代码将力施加在刚体的质心(
rb.AddForce)。这会产生一个纯粹的线性力,物体会被推开。如果你希望物体还能旋转起来(比如被炸飞的木板),可以考虑使用rb.AddForceAtPosition,在碰撞点施加力,这样会产生一个扭矩(Torque),让物体旋转。这会让爆炸效果看起来更真实。 - 归一化距离:将距离除以半径,得到0到1之间的值,再用这个值去采样衰减曲线。这是处理各种半径爆炸的通用方法。
4.3 提升真实感的进阶技巧
冲击波传播效果:一个真实的爆炸,冲击波是逐渐扩散的。我们可以用协程(Coroutine)来模拟:
IEnumerator SimulateShockwave(Vector3 center, float maxRadius, float speed) { float currentRadius = 0f; while (currentRadius < maxRadius) { // 每一帧,对当前半径的一个薄壳范围内的物体施加力(力较小) // 可以使用Physics.OverlapSphere,但设置一个很小的厚度(如currentRadius-0.1f, currentRadius) // 或者更高效地,使用Physics.SphereCast非分配版本(Physics.SphereCastNonAlloc) currentRadius += speed * Time.deltaTime; // ... 对薄壳内物体施加力 ... yield return null; // 下一帧继续 } }这种方法计算量更大,但视觉效果(特别是配合一个逐渐放大的透明球体特效)极其震撼。
碎片与二次爆炸:被炸飞的碎片(通过
FractureObject生成)本身应该带有刚体和碰撞器。并且,这些碎片如果本身是易燃易爆物(比如另一个汽油桶的碎片),在受到足够大的冲击时,应该能触发二次爆炸。这需要在碎片上挂载一个脚本,检测受到的力或碰撞,并判断是否达到引爆阈值。音效的空间化与衰减:使用
AudioSource.PlayClipAtPoint虽然简单,但缺乏方向感。对于重要的爆炸,最好实例化一个带有AudioSource的GameObject,并设置其spatialBlend为1(完全3D音效),rolloffMode为Logarithmic(对数衰减),这样玩家就能通过耳机听出爆炸的方向和远近。
5. 性能优化与常见问题深度排查
物理效果很酷,但也是性能杀手。下面是一些实战中总结的优化和排错经验。
5.1 性能优化清单
- 控制动态刚体数量:这是铁律。屏幕上同时活动的动态刚体最好不超过50-100个(取决于平台)。对于爆炸产生的大量碎片,可以考虑:
- 使用简化碰撞体:碎片用Sphere或Box代替Mesh Collider。
- 设置自动销毁:碎片在飞行几秒后,如果速度降到阈值以下,就将其刚体设为
Kinematic,然后过一会儿再销毁。或者直接使用对象池回收。 - 降低物理更新频率:在
Project Settings -> Time中,可以适当调低Fixed Timestep(如从0.02调到0.04),但这会影响物理精度和手感,需谨慎测试。
- 善用
Rigidbody属性:Sleep Threshold(睡眠阈值):当刚体速度低于此值时会进入休眠,停止物理计算。对于碎片,可以适当调高这个值,让它们更快“睡着”。Collision Detection(碰撞检测):对于高速运动的物体(如子弹),使用Continuous(连续检测)或Continuous Dynamic防止穿模。对于低速或一般物体,使用Discrete(离散检测)即可,性能更好。Interpolation(插值):如果刚体运动有抖动,可以尝试开启Interpolate。它通过渲染帧之间的平滑来改善视觉表现,但会消耗额外内存。
- 使用
Physics.OverlapSphereNonAlloc:这是Physics.OverlapSphere的高性能版本。它需要你预先分配一个Collider[]数组作为缓冲区,避免GC(垃圾回收)分配。对于每帧都需要进行的爆炸检测(比如持续燃烧区域),必须使用这个方法。private Collider[] overlapBuffer = new Collider[50]; // 预分配缓冲区 void Update() { int numColliders = Physics.OverlapSphereNonAlloc(transform.position, radius, overlapBuffer, layerMask); for (int i = 0; i < numColliders; i++) { // 处理 overlapBuffer[i] } }
5.2 常见问题与解决方案速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 物体爆炸后抖动或抽搐 | 1. 多个碰撞体形状复杂,穿透后物理引擎反复纠正。 2. 爆炸力施加过于频繁(每帧都在加力)。 3. 刚体质量(Mass)差异过大,小物体被大物体撞飞后速度过快。 | 1. 简化碰撞体,确保它们初始位置不穿透。 2. 确保爆炸力只施加一次(使用 Invoke或协程控制)。3. 统一或合理设置场景中刚体的质量比例,避免极端差异。使用 Mathf.Clamp限制刚体的最大速度。 |
| 爆炸效果“很假”,物体像同时被无形的手推走 | 力的计算没有考虑距离衰减,或者衰减曲线设置不当。 | 严格按照4.2节的方法,使用AnimationCurve和归一化距离来计算衰减力。调整曲线形状,让近处力大,远处力小。 |
| 帧率在爆炸瞬间骤降 | 1. 瞬时实例化过多特效(粒子、碎片模型)。 2. OverlapSphere一次性检测到过多物体,且每个物体都进行了复杂计算。3. 碎片刚体过多,物理计算暴增。 | 1. 使用对象池管理爆炸特效和碎片。 2. 分帧处理爆炸影响:将受影响物体列表存入数组,用协程分几帧来处理施加力的逻辑。 3. 为碎片设置更短的存活时间和更快的睡眠阈值。 |
| 碰撞检测有时失灵(高速穿透) | 物体移动速度太快,在物理更新间隔(FixedUpdate)之间移动距离超过了自身尺寸,导致离散检测(Discrete)失效。 | 为高速物体(子弹、炮弹)的刚体将Collision Detection设置为Continuous或Continuous Dynamic。同时,可以考虑使用Raycast或SphereCast进行预测性的碰撞检测。 |
| 爆炸后碎片悬空或缓慢下落 | 碎片刚体进入了“睡眠”状态,但后续没有力唤醒它。或者场景中持续存在微小的力(如风区)阻止其睡眠。 | 检查是否错误地对碎片施加了持续的力(ForceMode.Force)。确保爆炸后,除了重力没有其他持续力作用。可以适当调低刚体的Sleep Threshold。 |
OnCollisionEnter不被调用 | 1. 双方都是触发器(Is Trigger)。 2. 其中一方没有刚体。 3. 碰撞层级矩阵(Layer Collision Matrix)中,双方层级未勾选交互。 4. 刚体被设置为 Kinematic,且另一方也是Kinematic或静态碰撞体。 | 1. 确认是否需要物理反应,不需要则用OnTriggerEnter,需要则取消触发器勾选。2. 确保至少一方有非运动学刚体。 3. 去 Physics Settings中检查层级碰撞矩阵。4. Kinematic刚体不会因碰撞触发OnCollisionEnter,但可以通过代码移动它去触发其他物体的OnCollisionEnter。 |
6. 从理论到实践:一个手雷爆炸的完整案例
让我们把所有知识串联起来,实现一个经典的FragGrenade(破片手雷)。
步骤分解:
手雷本体(Grenade.prefab):
- 组件:
Rigidbody(质量0.3,使用重力,Drag设为0.5增加空气阻力感),Sphere Collider(半径0.1,非触发器)。 - 脚本:
Grenade.cs。负责投掷逻辑、碰撞检测(碰到地面或敌人后触发延时爆炸)、倒计时。
public class Grenade : MonoBehaviour { public ExplosionProfile explosionProfile; // 配置爆炸参数 public float fuseTime = 3f; // 引信时间 private bool hasExploded = false; void Start() { Invoke(nameof(Explode), fuseTime); } void OnCollisionEnter(Collision collision) { // 如果碰撞很剧烈,或者碰到敌人,立即引爆 if (collision.impulse.magnitude > 5f || collision.gameObject.CompareTag("Enemy")) { CancelInvoke(nameof(Explode)); Explode(); } } void Explode() { if (hasExploded) return; hasExploded = true; ExplosionSystem.Instance.ExecuteExplosion(transform.position, explosionProfile); // 隐藏模型,播放一段延迟消失的音频尾音,然后销毁或回收入对象池 GetComponent<MeshRenderer>().enabled = false; Destroy(gameObject, 2f); } }- 组件:
爆炸系统单例(ExplosionSystem.cs):
- 设计为单例模式,方便全局访问。包含上文
ExecuteExplosion方法。 - 在
Awake中预加载一个爆炸特效和音效的对象池。
- 设计为单例模式,方便全局访问。包含上文
爆炸效果配置(ExplosionProfile_Frag.asset):
radius: 8baseForce: 1200forceFalloff: 一条从(0,1)陡峭下降到(0.5, 0.2)再平缓到(1,0)的曲线。baseDamage: 80damageFalloff: 类似力衰减,但中远距离伤害降为0。upwardModifier: 0.3 (让物体稍微飞起)。affectedLayers: 包含Enemy,Destructible,PhysicsObject,但不包含Player(友军伤害?看设计)和IgnoreRaycast。
被影响物体:
- 敌人:挂载
IDamageable接口的实现,在TakeDamage方法里处理扣血和死亡。 - 可破坏木箱:挂载
FractureObject脚本和IDamageable。当受到伤害超过阈值时,调用Shatter方法,将自己替换为一系列预破碎的碎片模型(每个碎片都有刚体和碰撞器)。 - 碎片:碎片预制体上有一个
Debris脚本,Start时给自己一个随机的小扭矩让旋转更自然,并设置Invoke(nameof(StopPhysics), 3f),3秒后将刚体设为Kinematic并停止所有粒子,等待对象池回收。
- 敌人:挂载
最终效果:玩家投出手雷,手雷在空中划过抛物线,落地碰撞(或计时结束)。ExplosionSystem在爆炸点生效,根据配置的曲线计算范围内每个刚体受到的力和伤害。近处的敌人被炸飞并受到高额伤害,木箱破碎成多个飞溅的碎片,碎片在飞行后逐渐静止。屏幕伴随一次短促的震动,配合震撼的音效和粒子特效,一个富有冲击感的爆炸就完成了。
这个案例的关键在于将所有环节参数化、可配置。通过调整ExplosionProfile,你可以轻松创造出燃烧弹、冲击波手雷、黏性炸弹等不同变体,而无需修改核心代码。这种数据驱动的设计,对于迭代和平衡游戏体验至关重要。
