1. 项目概述:从“撞上”到“穿过”的物理世界构建
在Unity里做游戏,尤其是涉及到角色移动、物体交互、战斗判定时,有两个组件你绝对绕不开:碰撞器(Collider)和触发器(Trigger)。新手和老手的分水岭,往往就在于对这两者微妙差异的理解和运用上。表面上看,它们都是挂在GameObject上的一个“形状”,用来定义物体的物理边界。但内核里,一个负责“硬碰硬”的物理模拟,另一个则专注于“悄无声息”的事件检测。我见过太多项目,因为用错了这两者,导致角色卡墙、子弹穿模、道具拾取失灵,或者更糟——性能莫名其妙地掉帧。
简单来说,碰撞器是物理世界的“实体墙”,有质量、有摩擦、会反弹;而触发器是物理世界的“幽灵区域”,物体可以自由穿过,但会留下“到此一游”的记录。这篇文章,就是把我这些年踩过的坑、总结的经验,掰开揉碎了讲给你听。无论你是刚入门Unity,想搞明白为什么自己的小球总掉出地图,还是已经有一定经验,但总被一些诡异的碰撞检测问题困扰,这里都有你想要的答案。我们会从最基础的概念讲起,一直深入到性能优化和那些官方手册里不会写的实战避坑技巧。
2. 核心概念拆解:碰撞器、刚体与触发器的三角关系
要玩转Unity的物理交互,必须彻底理清三个核心组件的关系:碰撞器(Collider)、刚体(Rigidbody)和触发器(Trigger)。很多人把它们混为一谈,这是万恶之源。
2.1 碰撞器:物体的“形”
碰撞器定义了物体的物理形状。记住,它只负责形状,不负责运动。你可以把它想象成一个不可见的、包裹着模型的外壳。Unity提供了多种原始碰撞器(Primitive Colliders):
- 3D:盒状碰撞器(Box Collider)、球状碰撞器(Sphere Collider)、胶囊碰撞器(Capsule Collider)。
- 2D:盒状碰撞器2D(Box Collider 2D)、圆形碰撞器2D(Circle Collider 2D)。
一个物体可以添加多个碰撞器来组合成复杂形状,这就是复合碰撞器(Compound Collider)。比如,一个椅子可以用一个盒状碰撞器做座位,四个胶囊碰撞器做腿。关键技巧:复合碰撞器的根物体上通常只需要一个刚体,子物体上挂碰撞器即可,这样能保持较好的性能。
注意:网格碰撞器(Mesh Collider)虽然能完美贴合复杂模型,但性能开销巨大,且默认情况下两个网格碰撞器之间无法产生碰撞(除非勾选Convex选项生成凸包)。一条黄金法则是:场景静态几何体用网格碰撞器,移动的物体用复合原始碰撞器来近似。
2.2 刚体:物体的“魂”
刚体赋予了物体物理属性,如质量、重力、阻力、角阻力。是刚体让物体能受力运动、参与物理模拟。没有刚体的碰撞器,我们称之为静态碰撞器(Static Collider),比如地面、墙壁。它们永远不动,物理引擎会对它们做大量优化。
带有刚体的碰撞器,我们称之为动态碰撞器(Dynamic Collider)或刚体碰撞器。它们是物理世界中的“活跃分子”。这里还有一个特殊变种:运动学刚体碰撞器(Kinematic Rigidbody Collider)。它的刚体组件勾选了Is Kinematic。这种物体不会被物理力(如重力、碰撞力)推动,但你可以通过代码(修改Transform)来移动它。它移动时,会像一堵移动的墙一样推开其他动态刚体。
2.3 触发器:事件的“哨兵”
触发器本质上是一个勾选了Is Trigger属性的碰撞器。它关闭了物理碰撞响应,物体可以穿透它,但它会检测穿透事件。你可以把它理解为一个无形的感应区域。当另一个带有碰撞器的物体进入、停留或离开这个区域时,触发器会发送消息(OnTriggerEnter, OnTriggerStay, OnTriggerExit)。
核心区别:普通碰撞器产生“力”的交互(阻挡、反弹);触发器只产生“事件”的通知。
它们三者的组合,决定了物理交互的行为。下表清晰地展示了不同组合下,碰撞检测和消息发送的规则:
| 物体A \ 物体B | 静态碰撞器 | 动态刚体碰撞器 | 运动学刚体碰撞器 |
|---|---|---|---|
| 静态碰撞器 | 无碰撞/消息 | A检测到B, B被阻挡 | A检测到B, B被阻挡 |
| 动态刚体碰撞器 | A被阻挡,B检测到A | 互相检测,互相阻挡 | 互相检测,A被阻挡 |
| 运动学刚体碰撞器 | A检测到B, B被阻挡 | A被阻挡,B检测到A | 互相检测,互相阻挡 |
注:“检测到”指该物体的脚本会收到OnCollisionEnter/Stay/Exit消息。
对于触发器,规则更简单:只要双方都有碰撞器(无论是否刚体),且至少一方是触发器,就会发送触发器消息。发送给谁?发送给双方所有挂载了脚本的物体。
3. 实战配置与脚本编写
理解了理论,我们来看看具体怎么用。这里分场景配置和代码监听两部分。
3.1 场景配置:如何正确设置你的物体
情况一:一堵静止的墙(静态碰撞器)
- 创建一个Cube(立方体)。
- 它默认带有盒状碰撞器(Box Collider)。不要添加刚体(Rigidbody)。
- 完毕。这就是最典型的静态碰撞器,用于所有不会移动的环境物体。
情况二:一个可以被推动的木箱(动态刚体碰撞器)
- 创建一个Cube。
- 确保它有盒状碰撞器。
- 添加刚体组件(Rigidbody)。此时,重力会使其下落。
- 你可以通过代码(如
rigidbody.AddForce)或与其他动态物体碰撞来推动它。
情况三:一扇需要手动打开的门(运动学刚体碰撞器)
- 创建一个Cube作为门。
- 添加碰撞器和刚体。
- 在刚体组件上,勾选
Is Kinematic。 - 现在,这扇门不会被物理力推动,但你可以用代码(例如
transform.Rotate)来旋转它。当它旋转碰到玩家(动态刚体)时,会把玩家“推”开。
情况四:一个拾取金币的区域(触发器)
- 创建一个Sphere(球体)作为拾取范围。
- 在其Sphere Collider组件上,勾选
Is Trigger。 - (可选)为了美观,你可以让这个球体半透明。关键点:这个触发器是否需要刚体?答案是:如果你需要在脚本里接收OnTrigger事件,那么它必须有一个碰撞器,并且至少它自己或对方有刚体。通常,我们给触发器物体也添加一个刚体,并勾选
Is Kinematic,防止它被重力影响掉下去,同时确保能稳定接收事件。
3.2 脚本监听:如何响应碰撞与触发事件
Unity通过消息方法(Message)来通知我们物理事件的发生。你需要在挂载到物体上的脚本中,定义这些方法。
碰撞事件监听:
void OnCollisionEnter(Collision collision) { // 当碰撞开始时调用一次 Debug.Log("撞到了:" + collision.gameObject.name); // 可以通过collision.contacts获取碰撞点信息 // 可以通过collision.impulse获取碰撞冲量 } void OnCollisionStay(Collision collision) { // 当碰撞持续时,每帧调用 // 可用于持续性的伤害计算等 } void OnCollisionExit(Collision collision) { // 当碰撞分离时调用一次 Debug.Log("离开了:" + collision.gameObject.name); }2D物理对应的是OnCollisionEnter2D(Collision2D collision)等方法。
触发事件监听:
void OnTriggerEnter(Collider other) { // 当其他碰撞器进入本触发器范围时调用 if (other.CompareTag("Player")) { Debug.Log("玩家进入了触发器"); // 例如:拾取道具 Destroy(gameObject); // 销毁被拾取的道具本身 } } void OnTriggerStay(Collider other) { // 当其他碰撞器停留在触发器内时,每帧调用 } void OnTriggerExit(Collider other) { // 当其他碰撞器离开触发器范围时调用 Debug.Log("玩家离开了触发器"); }2D物理对应的是OnTriggerEnter2D(Collider2D other)等方法。
重要心得:在
OnTriggerEnter中直接Destroy(gameObject)是安全的。但在OnCollisionEnter中,如果你要销毁发生碰撞的两个物体中的一个,需要非常小心,特别是当碰撞计算还在进行时。更安全的做法是使用Destroy(gameObject, 0.1f)延迟一小段时间销毁,或者设置一个bool标记,在Update或协程中处理销毁。
4. 深度避坑指南与性能优化
这部分是血泪经验的总结,很多问题官方文档一笔带过,但实际开发中却能让你头疼好几天。
4.1 坑点一:静态碰撞器的“静止”陷阱
问题:你以为静态碰撞器(无刚体)完全不能动,于是你用代码去移动它(比如一个升降平台)。结果发现物理表现诡异,物体可能穿模,或者性能急剧下降。
原理:物理引擎(如PhysX)对静态碰撞器做了极强的优化,它假设这些物体永不移动。当你运行时移动它,物理引擎需要内部重建其空间加速结构(如BVH树),开销极大,且容易导致当前帧的碰撞检测失效。
解决方案:
- 对于需要移动的环境物体,永远不要用无刚体的静态碰撞器。
- 使用运动学刚体碰撞器(Kinematic Rigidbody)。给它加上刚体并勾选
Is Kinematic,然后用代码移动Transform。这样物理引擎会将其作为可移动物体正确处理,性能尚可,且能正确推开其他动态刚体。 - 如果这个移动物体完全不会与其他物体发生力的交互(比如只是一个视觉上的移动背景),可以考虑不用碰撞器,或者使用触发器。
4.2 坑点二:缩放(Scale)引发的血案
问题:你在Inspector里缩放了一个带有碰撞器的物体,或者它的父物体。运行时发现碰撞检测的形状和视觉模型对不上,要么大了要么小了。
原理:碰撞器的大小(如Box Collider的Size/中心)会受到物体及其所有父物体Transform缩放的影响。非均匀缩放(Scale的x, y, z值不同)会导致碰撞形状变成歪斜的盒体或椭球体,这可能与物理引擎的某些简化计算产生预期外的结果。
解决方案:
- 黄金法则:尽量避免在运行时缩放带有碰撞器的物体,尤其是非均匀缩放。
- 如果必须缩放,尽量在建模阶段就处理好模型的尺寸,在Unity中保持Scale为(1,1,1)。
- 如果必须通过代码动态调整碰撞范围,优先考虑直接修改碰撞器组件的参数,而不是缩放Transform。例如,调整
BoxCollider.size而不是transform.localScale。 - 对于复合碰撞器,子物体碰撞器的缩放会叠加父物体的缩放,计算起来更复杂,更要避免。
4.3 坑点三:一帧内的多次事件与刚体睡眠
问题:一个高速运动的物体(比如子弹)可能在一帧内直接穿过一个薄薄的触发器或碰撞器,导致OnTriggerEnter和OnTriggerExit都被调用,但OnTriggerStay没调用,或者更糟,一个事件都没触发。
原理:物理模拟是离散的,在固定的时间步长(Fixed Timestep,默认0.02秒)进行。如果物体速度太快,可能在这一步时还在触发器前面,下一步时已经穿到了后面,错过了检测。
解决方案:
- 连续碰撞检测(Continuous Collision Detection):给高速运动的刚体(如子弹)设置碰撞检测模式为
Continuous或Continuous Dynamic。这会让物理引擎在物体路径上进行更密集的采样,极大减少穿透几率,但会显著增加性能消耗。只对少数高速物体使用。 - 射线补偿(Raycasting):对于子弹,更常见的做法是根本不使用碰撞器,而是每帧从上一帧位置到当前帧位置发射一条射线(
Raycast)或使用球体投射(SphereCast)。这种方法100%可靠,且性能可控。 - 增大触发器/碰撞器体积:对于入口区域,适当把触发器做厚一点。
刚体睡眠问题:为了性能,当刚体静止一段时间后,物理引擎会将其置为“睡眠”状态,不再计算其物理。如果你通过代码直接设置它的位置(transform.position)来“传送”它,它可能不会立刻唤醒,导致碰撞失效。此时需要调用rigidbody.WakeUp()来手动唤醒它。
4.4 坑点四:物理材质(Physics Material)的滥用
问题:给物体加了物理材质想让其更“滑”或更“弹”,结果物体行为怪异,或者下坡时越滑越快停不下来。
原理:物理材质主要控制碰撞时的摩擦力和弹力(反弹系数)。Dynamic Friction(动摩擦)和Static Friction(静摩擦)通常不需要调整得太极端。Bounciness(弹力)为1表示完全弹性碰撞,但结合摩擦力,能量损耗可能不符合直觉。
一个经典陷阱:想让冰面很滑,就把摩擦力设为零。但物体在斜面上会因为重力持续加速,且没有摩擦力使其停下来,这可能不是你想要的“滑”的效果。更真实的做法是使用较低的摩擦力,但同时可能需要在脚本中增加空气阻力或速度钳制。
解决方案:
- 除非必要,否则使用默认(None)物理材质。
- 需要冰面效果?可以创建一个低摩擦力(如0.05)、零弹力的材质。
- 需要超级弹球?创建一个高弹力(如0.9)、低摩擦力的材质。
- 2D和3D的物理材质不通用!3D叫
Physic Material,2D叫Physics Material 2D,别搞混了。
4.5 性能优化要点
- 层级划分(Layer)与碰撞矩阵(Layer Collision Matrix):这是最重要的优化手段。通过Edit -> Project Settings -> Physics(2D) 打开碰撞矩阵。取消勾选那些永远不需要相互碰撞的层。例如,“UI”层和“Enemy”层就不需要检测碰撞。这能极大减少物理引擎每帧需要检测的碰撞对。
- 简单形状优先:永远优先使用盒体、球体、胶囊体。一个胶囊体碰撞器比一个网格碰撞器性能高几个数量级。
- 合理使用触发器:触发器虽然不产生物理反馈,但检测本身也有开销。避免使用大量重叠的、每帧都有物体进出的复杂触发器。
- 静态物体的合并:对于大量不会移动的、使用网格碰撞器的静态物体(如场景碎石),考虑使用Unity的静态合批(Static Batching)或第三方工具将其碰撞器也进行某种形式的合并简化,但这属于高级优化范畴。
- 控制Fixed Timestep:在Edit -> Project Settings -> Time中,
Fixed Timestep决定了物理更新的频率。降低它(如从0.02降到0.04)能提升性能,但会降低物理模拟的精度和流畅度,需权衡。
5. 高级应用与技巧
5.1 实现一个“压力板”机关
这是一个综合运用触发器和运动学刚体的好例子。
- 创建一个扁平的Cube作为压力板模型。为其添加盒状碰撞器,并勾选Is Trigger。
- 给压力板添加刚体,并勾选
Is Kinematic。这样它自己不会动,但能可靠地检测触发事件。 - 编写脚本:
public class PressurePlate : MonoBehaviour { public GameObject door; // 需要控制的门或其他物体 private int objectsOnPlate = 0; // 记录压在板上的物体数量 void OnTriggerEnter(Collider other) { if (other.CompareTag("Moveable") || other.CompareTag("Player")) { objectsOnPlate++; if (objectsOnPlate == 1) { // 第一个物体压上,触发开门 door.GetComponent<DoorController>().Open(); } } } void OnTriggerExit(Collider other) { if (other.CompareTag("Moveable") || other.CompareTag("Player")) { objectsOnPlate--; if (objectsOnPlate == 0) { // 所有物体离开,触发关门 door.GetComponent<DoorController>().Close(); } } } }这个设计允许多个物体同时压在板上保持机关激活,只有全部离开才关闭,比简单的布尔值判断更健壮。
5.2 射线检测(Raycast)与碰撞检测的协同
很多时候,我们不需要持续的碰撞,只需要知道“前方是否有东西”。这时就用射线检测。它比持续存在的碰撞器性能开销低得多。
void Update() { // 从当前物体位置,向前方发射一条长度为10的射线 RaycastHit hit; if (Physics.Raycast(transform.position, transform.forward, out hit, 10f)) { Debug.Log("射线打中了:" + hit.collider.gameObject.name); // 可以在hit.point处生成一个击中特效 // 可以根据hit.collider.tag判断击中了什么 } }在射击游戏中,子弹常用射线检测;在角色控制器中,常用射线或球形检测(SphereCast)来检测是否着地。
5.3 2D与3D物理的差异提醒
虽然概念相通,但2D物理(Physics2D)和3D物理(Physics)是两套独立的系统,API和组件完全不同,不能混用。
- 2D物理使用
Rigidbody2D,Collider2D(如BoxCollider2D)。 - 事件方法是
OnCollisionEnter2D(Collision2D)和OnTriggerEnter2D(Collider2D)。 - 2D的物理材质是
Physics Material 2D。 - 最重要的一点:2D物理的Z轴是忽略的。所有2D碰撞器和刚体都假设在同一个Z轴平面上。如果你的精灵在Z轴上错开,碰撞可能失效。
6. 调试与问题排查
当碰撞/触发不按预期工作时,按以下步骤排查:
- 检查组件是否存在:双方都有碰撞器吗?至少一方有刚体吗?(对于触发器事件,双方都需要碰撞器,且至少一方有刚体)。
- 检查图层(Layer):双方所在的图层在碰撞矩阵中是否允许相互碰撞?这是最容易被忽略的一点。
- 检查缩放(Scale):碰撞器的Gizmo显示形状是否和视觉模型匹配?检查所有父物体的缩放值。
- 检查Is Trigger:你想要的到底是物理碰撞还是触发事件?属性勾对了吗?
- 检查刚体类型:是静态、动态还是运动学?参考第2部分的表格,确认当前组合是否能产生你期望的检测和消息。
- 使用Debug Draw:在代码中使用
Debug.DrawRay或Debug.DrawLine绘制射线,可视化你的检测逻辑。在Scene视图中,可以通过Gizmos菜单开启显示碰撞器线框,确保它们在你认为的位置。 - 检查速度:物体是否移动过快导致穿透?考虑使用连续碰撞检测或射线投射。
- 查看日志:确保你的
OnCollisionEnter或OnTriggerEnter方法被正确调用,并打印出碰撞对象的信息,确认不是你脚本中的条件判断(如Tag检查)过滤掉了事件。
物理交互是游戏真实感和趣味性的基石,也是Bug的高发区。理解碰撞器、刚体、触发器的本质差异,遵循“静态物体别乱动”、“形状尽量简单”、“善用图层过滤”这些基本原则,就能避开大多数深坑。剩下的,就是根据具体游戏逻辑,灵活搭配这些基础组件,创造出丰富有趣的交互体验了。记住,当你遇到奇怪的物理问题时,回头看看这篇文章的避坑指南,很可能答案就在其中。