1. 项目概述:碰撞检测为何是游戏物理的基石
在Unity引擎里折腾过一阵子的开发者,无论是做一款横版跳跃游戏,还是一个需要精确交互的VR应用,迟早都会和物理引擎正面“碰撞”。这个“碰撞”不是比喻,而是实实在在的、决定你游戏里角色会不会穿墙、子弹能不能击中目标、箱子能不能被推走的核心机制——碰撞检测。很多新手,包括当年的我,都曾天真地以为给模型挂上一个碰撞体(Collider)就万事大吉,结果要么是角色卡在莫名其妙的地方动弹不得,要么是高速运动的物体像幽灵一样穿过障碍物,调试起来让人头大。
实际上,Unity的物理引擎(默认是NVIDIA PhysX)提供的碰撞检测是一套极其精密又充满“脾气”的系统。它远不止是“两个盒子碰在一起”那么简单,其背后涉及到离散与连续检测的权衡、性能与精度的博弈、以及触发器与碰撞体在逻辑上的根本区别。理解这套机制,是你从“能用Unity做东西”到“能做好东西”的关键一步。无论是解决诡异的物理Bug,还是为了实现特定的游戏手感(比如《蔚蓝》里那种像素级的精准平台跳跃),深入碰撞检测的细节都必不可少。这篇文章,我就结合自己踩过的无数个坑,把Unity的碰撞检测机制掰开揉碎了讲清楚,让你不仅知道怎么用,更明白为什么要这么用。
2. 碰撞检测的核心组件与工作原理拆解
在深入机制之前,我们必须先理清参与碰撞检测的几个核心演员:刚体(Rigidbody)、碰撞体(Collider)和物理材质(Physic Material)。它们各司其职,共同演绎了物理交互这场大戏。
2.1 刚体:物理世界的入场券
刚体组件是物体参与物理模拟的“身份证”。没有刚体,一个物体在物理引擎眼里就是一片虚无的背景,即使它有碰撞体,也不会被计算受力、速度和碰撞响应。你可以把它理解为一个物体的“物理质量中心”。
关键属性解析:
- 质量(Mass):这是最容易被误解的参数之一。质量不是模型的视觉大小,而是物理惯性。一个视觉上巨大的气球质量可以很小,而一个视觉上的小铁球质量可以很大。质量比直接影响碰撞后的运动状态。例如,质量比为10:1的两个球碰撞,质量小的球会被狠狠弹开。
- 碰撞检测模式(Collision Detection):这是解决高速物体穿透问题的关键,我们会在后面详细展开。
- 插值(Interpolation):用于平滑基于物理运动的物体的渲染。物理计算在固定的时间步长(Fixed Timestep)中进行,而渲染帧率(Update)是波动的。这会导致基于物理运动的物体看起来有“抖动”。
Interpolate模式使用上一帧的物理状态来平滑当前帧的渲染;Extrapolate则尝试预测下一帧的状态。对于由玩家直接控制或需要极度平滑视觉的对象(如跟随摄像机),开启插值很有必要。 - 约束(Constraints):可以冻结物体在某个或某些轴上的移动或旋转。这是实现2D游戏效果(冻结Z轴移动和X、Y轴旋转)或制作滑动门的常用手段。
实操心得:对于完全由动画或脚本驱动、但需要与其他物理物体交互的物体(比如一个被动画控制的摆锤),可以勾选
Is Kinematic。这样它本身不受物理力影响,但它的运动会以巨大的质量去影响其他非Kinematic的刚体。常用于陷阱、移动平台。
2.2 碰撞体:物体的物理轮廓
碰撞体定义了物体的“物理形状”。它不一定,也通常不应该和模型的视觉网格(Mesh)完全一致。用高精度的视觉网格做碰撞体是性能杀手。
常见碰撞体类型与选用指南:
| 碰撞体类型 | 性能开销 | 精度 | 典型应用场景 |
|---|---|---|---|
| 盒型碰撞体 (Box Collider) | 极低 | 低(AABB包围盒) | 墙壁、地板、箱子、门、桌子等规则物体。 |
| 球形碰撞体 (Sphere Collider) | 极低 | 低 | 球体、角色(粗略的)、炮弹、能量球。 |
| 胶囊碰撞体 (Capsule Collider) | 低 | 中 | 第三人称/第一人称角色控制器的首选。能很好地模拟人体,且上下坡时不易卡住。 |
| 网格碰撞体 (Mesh Collider) | 非常高 | 高(可贴合复杂网格) | 极其复杂的静态环境(如一块奇形怪状的岩石),且必须勾选“Convex”才能与其它Mesh Collider碰撞。动态物体慎用。 |
| 车轮碰撞体 (Wheel Collider) | 中 | 特殊 | 专为车辆模拟设计,包含悬挂、摩擦力等高级参数。 |
一个重要的概念:复合碰撞体(Compound Collider)。一个游戏对象可以附加多个基本碰撞体(Box, Sphere, Capsule)来近似复杂形状。例如,一个椅子可以由一个盒体(座位)和四个胶囊体(腿)组合而成。这比使用一个Mesh Collider性能要好得多,也更容易控制。
踩坑记录:早期我曾用一个精细的角色视觉网格直接生成Mesh Collider,结果游戏在移动端上帧率暴跌。后来改用胶囊体+几个小盒体(用于模拟手臂伸展)组成的复合碰撞体,效果几乎一样,性能提升巨大。黄金法则:用最简单的碰撞体形状去近似你的物体。
2.3 物理材质:定义碰撞表面的“性格”
物理材质决定了两个表面接触时的行为,主要是摩擦力和弹性(反弹系数)。你可以把它想象成给物体表面覆盖了一层橡胶、冰面或钢铁。
- 动态摩擦力 (Dynamic Friction):物体已经运动时,与表面接触产生的摩擦力。
- 静态摩擦力 (Static Friction):物体从静止到开始运动所需要克服的摩擦力。通常静态摩擦力略大于动态摩擦力。
- 弹力 (Bounciness):取值范围0到1。0表示完全无弹性碰撞(如橡皮泥砸地),1表示完全弹性碰撞(理想情况,能量无损失)。两个碰撞物体的弹力值会通过
Bounce Combine模式(默认为平均)进行合并,得出最终弹力效果。 - 摩擦力合并/弹力合并 (Friction/Bounce Combine):定义当两个拥有不同物理材质的物体碰撞时,如何计算最终的摩擦力和弹力。有“平均”、“最小”、“最大”、“相乘”等模式。例如,一个弹力为0.8的篮球砸在弹力为0.3的泥地上,如果使用“平均”模式,则合并弹力为0.55。
3. 碰撞检测的两种模式:离散与连续
这是Unity碰撞检测中最核心、也最容易出问题的地方。物理引擎默认的更新频率(FixedUpdate)是有限的(通常每秒50次,即0.02秒一次)。在这0.02秒内,物体会移动一段距离。如果这段距离超过了它自身或对方碰撞体的尺寸,就可能发生“隧道效应”——物体从障碍物的一侧直接穿越到了另一侧,没有触发任何碰撞检测。
3.1 离散检测 (Discrete):默认的性价比之选
这是默认设置。物理引擎只在每个固定的物理时间步(Fixed Timestep)检查物体是否相交。如果物体在两步之间移动得足够快,它就可能“跳过”检测。
适用场景:大多数低速或中等速度运动的物体。比如场景中缓缓移动的平台、受重力下落的箱子、角色的一般行走和跳跃。它的性能开销最小。
3.2 连续检测 (Continuous):针对高速运动的保险
为了解决“隧道效应”,Unity提供了连续检测模式。它通过预测物体在时间步间的运动轨迹(形成一个“扫掠体”),来检测是否会发生碰撞。
- Continuous:仅检测该刚体与静态碰撞体(即没有附加刚体的碰撞体)之间的连续碰撞。性能开销中等。
- Continuous Dynamic:检测该刚体与所有标记为Continuous或Continuous Dynamic的刚体之间的连续碰撞。这是最彻底的检测模式,性能开销也最大。
- Continuous Speculative:一种基于推测的连续检测,在某些边缘情况下比Continuous Dynamic更稳定,但原理更复杂,一般情况不常用。
配置策略(非常重要):
- 给高速运动的物体(如子弹、发射物、高速移动的玩家)的刚体设置
Continuous Dynamic。 - 给可能会被高速物体撞击的静态障碍物(如薄墙、地板边缘)所对应的碰撞体,勾选
Continuous检测(注意,静态物体没有刚体,这个设置在碰撞体组件上)。 - 对于大量低速运动的物体(如一堆碎屑、粒子),坚持使用
Discrete以节省性能。
避坑指南:不要无脑地将所有物体都设为Continuous Dynamic!我曾在一个有上百个物理小物件的场景中全开了连续检测,物理计算耗时直接增加了300%。正确的做法是按需启用。通常,只有玩家子弹和玩家角色本身需要
Continuous Dynamic,关键的薄型障碍物设为Continuous静态碰撞体,其他一律Discrete。
4. 碰撞与触发:两种交互逻辑的本质区别
这是概念上的分水岭,混淆两者会导致逻辑错误。
4.1 碰撞 (Collision)
碰撞是物理的、有反馈的交互。两个物体发生碰撞时,物理引擎会计算碰撞力,并根据质量、速度、物理材质等参数,自动改变它们的运动状态(反弹、滑动、停止)。同时,它会调用一系列碰撞事件函数。
关键事件函数:
OnCollisionEnter(Collision collisionInfo): 当碰撞开始时调用一次。OnCollisionStay(Collision collisionInfo): 在碰撞持续的每一帧(物理帧)调用。OnCollisionExit(Collision collisionInfo): 当碰撞结束时调用一次。
Collision参数包含了丰富的碰撞信息,如接触点(contacts)、相对速度(relativeVelocity)、碰撞法线(normal)等,可用于实现更复杂的逻辑,比如根据碰撞点播放特效。
4.2 触发 (Trigger)
触发是逻辑的、无形的交互。当一个碰撞体被勾选为Is Trigger后,它就变成了一个“感应区域”。物理引擎会忽略它的碰撞响应(物体可以穿过它),但会触发另一套事件函数。至少有一个参与方必须有刚体组件。
关键事件函数:
OnTriggerEnter(Collider other): 当其他碰撞体进入触发器范围时调用一次。OnTriggerStay(Collider other): 当其他碰撞体停留在触发器内时,每帧调用。OnTriggerExit(Collider other): 当其他碰撞体离开触发器范围时调用一次。
Collider参数是对方碰撞体的引用,你可以通过它获取对方的游戏对象(other.gameObject)等信息。
如何选择?
- 需要物理反馈(阻挡、弹开)时,用碰撞。例如:角色与墙壁、球与地面、汽车与障碍物。
- 仅需检测物体进入/离开某个区域,不改变其物理运动时,用触发。例如:拾取道具的区域、关卡检查点、敌人警戒范围、陷阱的伤害区域。
常见错误:试图在
OnTriggerStay里通过修改Rigidbody.velocity来模拟物理推动。这通常会导致奇怪的行为,因为触发器本身不参与物理求解。正确的做法是,如果需要在这个区域内有物理效果(如减速力场),应该使用碰撞,并通过OnCollisionStay施加力(AddForce)或直接修改速度,同时可能需要调整物理材质来改变摩擦力。
5. 碰撞过滤:图层与碰撞矩阵
在复杂的游戏场景中,并非所有物体都需要相互碰撞。让子弹和子弹之间、远处的装饰物和玩家之间进行碰撞检测纯属浪费性能。Unity使用图层(Layer)和碰撞矩阵(Collision Matrix)来高效地管理碰撞关系。
工作流程:
- 定义图层:在
Tags and Layers设置中,创建有意义的图层,如Player,Enemy,Bullet,Environment,Pickup,IgnoreRaycast等。 - 分配图层:将场景中的游戏对象分配到对应的图层。
- 配置碰撞矩阵:进入
Edit -> Project Settings -> Physics,在底部的Layer Collision Matrix中,通过复选框精确控制哪些图层能与哪些图层发生碰撞。
最佳实践示例:
Bullet层只与Enemy和Environment层碰撞,不与Bullet或Pickup层碰撞。Pickup层只与Player层碰撞。Environment层与几乎所有层都碰撞(除了IgnoreRaycast等特殊层)。- 可以创建一个
NoCollision层,用于那些纯粹视觉装饰、不需要任何碰撞的物体。
通过精细配置碰撞矩阵,你可以大幅减少物理引擎需要处理的碰撞对(Pair)数量,这是提升物理性能最有效的手段之一。
6. 物理查询:射线检测与形状投射
除了被动的碰撞检测,我们经常需要主动进行物理查询,比如判断玩家前方是否有障碍物(射线检测),或者判断一个角色能否移动到某个位置(形状投射)。
6.1 射线检测 (Raycast)
这是最常用、最高效的物理查询方法。它从一点向一个方向发射一条无形的射线,并返回击中的第一个(或多个)碰撞体信息。
// 简单的单点射线检测 RaycastHit hitInfo; if (Physics.Raycast(transform.position, transform.forward, out hitInfo, 10f)) { Debug.Log("击中了: " + hitInfo.collider.gameObject.name); Debug.Log("击中点: " + hitInfo.point); Debug.Log("距离: " + hitInfo.distance); } // 带图层遮罩的射线检测,只检测特定层 int layerMask = 1 << LayerMask.NameToLayer("Enemy"); // 只检测Enemy层 if (Physics.Raycast(transform.position, transform.forward, out hitInfo, 10f, layerMask)) { // 只处理击中Enemy层的逻辑 }高级用法:
Physics.SphereCast: 球体投射,相当于发射一个球体,用于判断一个有一定体积的物体(如角色)前方是否有空间。Physics.BoxCast: 盒体投射。Physics.CapsuleCast: 胶囊体投射,是角色移动检测(如判断是否着地、是否碰到侧墙)的利器。Physics.RaycastAll: 返回射线路径上所有击中的物体,用于实现穿透弹等效果。
6.2 形状检测 (Overlap)
用于检测在某个位置、某个形状内,是否存在其他碰撞体。它不关心“如何到达那里”,只关心“那里有什么”。
// 检测角色脚下是否有地面(球形检测) Collider[] hitColliders = Physics.OverlapSphere(transform.position + Vector3.down * 0.5f, 0.4f); bool isGrounded = hitColliders.Length > 0; // 检测一个区域内的所有敌人(盒形检测) Vector3 halfExtents = new Vector3(5, 2, 5); // 检测盒的大小 Collider[] enemies = Physics.OverlapBox(centerPoint, halfExtents, Quaternion.identity, enemyLayerMask);性能警告:射线和形状检测虽然强大,但每帧进行大量检测(尤其是在Update中)同样会消耗性能。一个常见的优化是将检测频率降低,例如在
FixedUpdate中进行,或者每2-3帧检测一次。对于AI的视野检测,尤其需要注意。
7. 实战:构建一个健壮的角色控制器
理论说再多,不如动手实现一个。这里我们不直接用CharacterController组件,而是用刚体+胶囊碰撞体来构建,以深入理解碰撞检测的每个环节。
7.1 基础设置
- 创建一个胶囊体作为角色模型。
- 添加
Rigidbody组件。冻结Y轴旋转(防止摔倒),设置质量为合理值(如70),碰撞检测模式设为Continuous Dynamic(防止高速移动时穿墙)。 - 添加
Capsule Collider组件,调整高度和半径以匹配模型。 - 创建一个脚本
PlayerMovement.cs并挂载。
7.2 移动与地面检测
我们使用力(AddForce)或直接修改速度(rigidbody.velocity)来实现移动。地面检测使用胶囊体投射(CapsuleCast)或底部球体检测(OverlapSphere),这比简单的射线检测更可靠,能应对斜坡和台阶边缘。
public class PlayerMovement : MonoBehaviour { public float moveSpeed = 10f; public float jumpForce = 7f; public LayerMask groundLayer; // 在Inspector中指定哪些层是地面 public float groundCheckRadius = 0.4f; public Transform groundCheckPoint; // 一个在角色脚底的空物体 private Rigidbody rb; private bool isGrounded; void Start() { rb = GetComponent<Rigidbody>(); } void FixedUpdate() // 物理操作放在FixedUpdate中 { // 1. 地面检测 isGrounded = Physics.CheckSphere(groundCheckPoint.position, groundCheckRadius, groundLayer); // 2. 水平移动(使用速度控制,响应更快) float moveX = Input.GetAxis("Horizontal"); float moveZ = Input.GetAxis("Vertical"); Vector3 moveDirection = new Vector3(moveX, 0, moveZ).normalized; Vector3 targetVelocity = moveDirection * moveSpeed; // 只改变水平速度,保留垂直速度(重力/跳跃) rb.velocity = new Vector3(targetVelocity.x, rb.velocity.y, targetVelocity.z); // 3. 跳跃 if (isGrounded && Input.GetButtonDown("Jump")) { rb.AddForce(Vector3.up * jumpForce, ForceMode.Impulse); // 使用冲量模式,瞬间施加力 } } // 可选:在Scene视图中绘制地面检测范围,便于调试 void OnDrawGizmosSelected() { if (groundCheckPoint == null) return; Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(groundCheckPoint.position, groundCheckRadius); } }7.3 解决“爬坡”和“卡墙”问题
上面的简单移动在遇到斜坡或墙角时可能会卡住。因为我们的移动直接设置了速度,没有考虑斜坡法线方向。
改进方案:使用Rigidbody.MovePosition配合射线/胶囊体检测进行碰撞预测。但更高级和常见的做法是使用“角色移动”力模式,并利用OnCollisionStay来获取碰撞信息,进行更复杂的响应。对于生产级角色控制器,许多开发者会选择使用或参考Unity官方的CharacterController组件(它内部处理了很多这类碰撞细节),或者使用像Kinematic Character Controller这样的第三方高级解决方案。但理解其底层基于碰撞检测的原理,对于调试和自定义功能至关重要。
8. 性能优化与常见问题排查
物理计算是CPU密集型任务。不当的使用会导致帧率下降,尤其是在移动设备上。
8.1 性能优化清单
- 精简碰撞体形状:重申,这是第一要务。用简单形状组合。
- 善用碰撞矩阵:禁用不必要的层间碰撞。
- 静态物体标记为静态:确保永远不会移动的环境物体(地形、建筑)在Inspector右上角勾选
Static标签。这允许Unity对其进行批量优化(如构建静态碰撞树)。 - 减少动态刚体数量:物理引擎最擅长处理少量动态物体。对于大量小物体(如碎片),考虑使用粒子系统或简单的非物理动画。
- 控制检测频率和范围:对于AI的视野检测、子弹的碰撞检测,可以适当降低频率或使用距离判断提前剔除。
- 避免在Update中调用密集的物理查询:移到
FixedUpdate中,或使用协程间隔执行。 - 谨慎使用Mesh Collider:仅用于极其复杂的静态地形,并尽量使用简化后的网格。
- 调整物理时间步长:
Edit -> Project Settings -> Time中的Fixed Timestep默认是0.02s(50Hz)。降低此值(如0.04s)可以减少物理更新频率,提升性能,但会降低物理模拟的平滑度。这是一个权衡。
8.2 常见问题排查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 高速物体穿透障碍物 | 碰撞检测模式为Discrete | 将高速物体的刚体设为Continuous Dynamic,并将薄障碍物设为Continuous静态碰撞体。 |
| 物体抖动或“游泳” | 多个碰撞体重叠;物理更新与渲染更新不同步 | 检查碰撞体是否意外交叉;为刚体开启Interpolation。 |
OnTrigger事件不触发 | 双方都没有刚体;或其中一方刚体为Kinematic且另一方为Trigger? | 确保至少一方有非Kinematic刚体。检查碰撞矩阵是否禁止了这两层的交互。 |
| 角色在斜坡上卡住或弹跳 | 碰撞体形状不合适;物理材质摩擦力/弹力过高 | 使用Capsule Collider代替Box Collider。调整物理材质的摩擦力,或通过脚本在检测到斜坡时施加一个沿斜坡方向的力。 |
| 物理表现不一致(PC vs. 移动端) | 帧率波动导致物理计算次数不同 | 确保所有物理相关的操作(AddForce,velocity修改)都在FixedUpdate中进行,而非Update。FixedUpdate的调用间隔是固定的。 |
| 性能突然下降 | 动态刚体数量激增;复杂Mesh Collider;每帧大量射线检测 | 使用Profiler的Physics模块分析。检查是否在循环中实例化了大量带刚体的物体,或是否有物体意外从静态变为动态。 |
理解Unity的物理引擎与碰撞检测,是一个从“知其然”到“知其所以然”的过程。它不像渲染那样直观,但却是游戏交互真实感的灵魂。开始的时候可能会被各种奇怪的Bug困扰,但每解决一个问题,你对这套系统的掌控力就加深一分。记住,没有银弹,最好的方案总是来自于对项目具体需求(是写实的物理模拟,还是风格化的平台跳跃)和性能约束的深刻理解。多实验,多调试,利用好Scene视图中的物理调试可视化(如碰撞体边框),你就能让游戏里的每一个碰撞都恰到好处。