Unity物理系统与碰撞检测实战:核心参数、配置技巧与问题排查

发布时间:2026/10/26 19:16:40
Unity物理系统与碰撞检测实战:核心参数、配置技巧与问题排查

物理系统和碰撞检测这块内容,在Unity项目里属于那种“平时不显眼、一出问题就要命”的模块。我做过的项目里,角色穿墙、子弹打不中、物体抖动、触发器莫名其妙不响应,追根溯源十有八九都落在物理配置和碰撞检测的细节上。这篇内容就是围绕Unity引擎下的C#物理系统与碰撞检验展开,把刚体、碰撞体、触发器、层碰撞矩阵、射线检测、连续碰撞这些核心点拆开讲透,同时补上大量实际项目里踩过的坑和调参经验。不管你是刚接触Unity物理的新手,还是已经写过一段时间但总觉得物理表现“不太对劲”的开发者,应该都能从里面找到能直接抄作业的配置思路和排查方法。下面我按设计思路、核心细节、实操流程、问题排查四个大块来讲,中间穿插参数计算和现场记录,尽量做到看完就能上手改。

1. 物理系统整体设计与方案选型思路

1.1 为什么Unity物理要分“物理世界”和“表现层”两层来理解

很多人一开始学Unity物理,容易把Transform和Rigidbody混着用,结果就是物体该动的时候不动、不该抖的时候狂抖。我习惯把Unity的物理体系理解成两个世界:一个是物理世界,由Rigidbody、Collider、物理材质、关节等组成,由物理引擎在固定时间步长里做积分运算;另一个是表现层,就是Transform最终呈现出来的位置和旋转。物理引擎算完之后,会把结果同步回Transform,但这个同步是有时序的。

这个区分为什么重要?因为一旦你在FixedUpdate之外直接改Transform,物理引擎是不知道的,它下一帧还会按自己算的位置覆盖回去,表现出来就是“我明明设置了位置,它又弹回去了”。我见过不少项目在Update里直接给带刚体的物体赋值position,然后抱怨碰撞检测不准,本质就是绕过了物理世界的计算。

所以整体设计的第一条原则:凡是参与物理模拟的物体,位移和旋转尽量交给Rigidbody去驱动,不要手动改Transform。需要瞬移的场景,用Rigidbody.position或者MovePosition,并且注意瞬移后物理引擎需要重新同步碰撞状态。这条原则定下来,后面很多诡异问题就少了一半。

1.2 固定时间步长与物理更新的关系,为什么它决定了碰撞精度

Unity物理默认的固定时间步长是0.02秒,也就是每秒50次物理更新。这个值在Project Settings的Time面板里可以改。它直接决定了物理模拟的“采样频率”,进而影响碰撞检测的精度。

举个直观的例子:一个物体以每秒20米的速度移动,在0.02秒的步长里,它一帧就移动了0.4米。如果这个物体的碰撞体厚度只有0.2米,那么它完全有可能在两次物理采样之间“穿过”一个薄墙,这就是典型的**穿透(tunneling)**问题。计算过程很简单:单步位移 = 速度 × 固定步长 = 20 × 0.02 = 0.4米,大于碰撞体厚度0.2米,穿透风险成立。

解决思路有三条:一是降低固定步长(比如改成0.01),代价是物理计算量翻倍;二是给高速物体开启连续碰撞检测(Continuous Collision Detection,CCD);三是加厚碰撞体或者用射线做补充检测。这三条在实际项目里往往是组合使用的,具体怎么选后面章节会展开。这里先建立一个认知:物理步长是碰撞精度的地基,调参之前先算清楚单步位移和碰撞体尺寸的关系。

1.3 碰撞体选型:Box、Sphere、Capsule、Mesh到底怎么挑

碰撞体的选型直接关系到性能和检测稳定性。我的经验排序是这样的:

碰撞体类型性能开销检测稳定性典型适用场景
Sphere最低最高球形投射物、球形触发区域
Capsule低高角色控制器、人形单位
Box低高箱子、平台、建筑块
Mesh(凸包)中中不规则但需要精确的物体
Mesh(非凸)高低静态复杂地形(需勾选Convex关闭)

核心原则是:能用基本几何体就不用Mesh,能用凸包就不用非凸。非凸Mesh碰撞体在动态物体上不仅性能差,而且物理引擎对它的支持有限(动态刚体要求碰撞体是凸的),容易出现检测异常。我做过一个道具散落的场景,一开始给每个道具都用Mesh Collider,帧率直接掉了一半,后来换成Box加少量Sphere组合,性能回来了,视觉上玩家根本看不出差别。

角色为什么普遍用Capsule?因为胶囊体在斜面、台阶上的滑动表现最平滑,没有Box的棱角卡顿问题,同时比Sphere更贴合人形。这是一个经过大量项目验证的选型,新手直接照做就行。

1.4 层碰撞矩阵:被低估的性能与逻辑双料开关

Unity的Layer Collision Matrix(层碰撞矩阵)在Project Settings的Physics面板里,它决定了哪些层之间会发生碰撞。这个东西很多人配置完就忘了,但它其实是性能和逻辑的双重开关。

性能上,关闭不需要的层间碰撞,物理引擎就少做一大批broadphase(粗筛)计算。逻辑上,它能让“玩家子弹不打玩家自己”“UI触发区不干扰场景物体”这类需求不用写一行代码就实现。我习惯在项目初期就把层规划好,比如Player、Enemy、Bullet、Environment、TriggerZone、Pickup这几层,然后在矩阵里把Bullet和Bullet、Bullet和Pickup这类不需要的组合关掉。

注意:层碰撞矩阵关闭的是“碰撞对”的检测,但射线检测(Raycast)默认不受它影响,射线需要单独用layerMask参数控制。这一点经常被混淆,后面排查章节会专门讲。

2. 核心细节解析与实操要点

2.1 Rigidbody关键参数逐个拆解,质量、阻力、插值怎么设

Rigidbody是物理模拟的核心组件,参数不多但每个都有讲究。我按实际调参顺序说。

Mass(质量):默认1。质量影响的是碰撞时的动量交换和受力响应。很多人以为质量越大下落越快,这是错的,重力加速度和质量的响应在物理引擎里是解耦的(忽略空气阻力时)。质量真正影响的是碰撞冲量分配,比如一个质量10的物体撞质量1的物体,前者几乎不受影响,后者被弹飞。做推箱子玩法时,箱子的质量要明显大于角色,否则角色一碰箱子就飞。

Drag(阻力)和Angular Drag(角阻力):默认0.05左右。这两个值控制速度衰减。做冰面滑行就把Drag调小,做水中漂浮就调大。我踩过的坑是给投射物设了较大的Drag,结果子弹飞出去没多远就停了,排查半天才发现是阻力吃掉了速度。

Use Gravity:是否受重力。飞行类投射物通常关掉,自己做抛物线计算或者用受力控制。

Interpolate(插值):这个参数对视觉流畅度影响巨大。物理以50Hz更新,渲染可能是60Hz甚至更高,两者不同步就会看到物体“一跳一跳”。开启Interpolate后,渲染帧会在两个物理帧之间做插值,视觉上就顺滑了。几乎所有玩家能看到的动态物理物体都应该开Interpolate,这是性价比极高的一个设置。

Collision Detection(碰撞检测模式):Discrete(离散)、Continuous(连续)、Continuous Dynamic(连续动态)、Continuous Speculative(连续推测)。默认Discrete,高速物体必须改成Continuous或Continuous Dynamic。区别在于Continuous Dynamic会对该物体与其他动态物体之间也做连续检测,开销更大但更稳。子弹这类高速小物体,我一般直接上Continuous Dynamic。

Constraints(约束):可以冻结某个轴的位移或旋转。做2D横版但用3D物理时,冻结Z轴位移和X、Y轴旋转是常规操作,能避免物体莫名其妙侧翻。

2.2 碰撞体与物理材质:摩擦力与弹性的组合逻辑

碰撞体(Collider)负责形状,物理材质(Physic Material)负责表面的摩擦和弹性。物理材质有两个核心参数:Dynamic Friction(动态摩擦)、Static Friction(静态摩擦)、Bounciness(弹性),以及Friction Combine和Bounce Combine两个组合模式。

组合模式决定了两个物体接触时,各自的材质参数怎么合并:Average(平均)、Minimum(取小)、Maximum(取大)、Multiply(相乘)。默认是Average。这里有个实操技巧:如果想让某个物体“绝对不弹”,把它的Bounciness设为0,同时把Bounce Combine设为Minimum,这样无论和什么弹性材质接触,结果都是0。同理,想让角色在斜坡上不滑,把摩擦的Combine设为Maximum并给高摩擦值。

我做过一个弹球玩法,球体材质Bounciness设0.9,地面设0.8,Combine用Average,实际弹跳高度衰减很自然。后来想做一个“超级弹板”,就把弹板的Bounciness设1.0且Combine设Maximum,球一碰就满血弹起,效果很直观。这套组合逻辑理解了,弹性表现基本可以不用写代码纯靠配置调出来。

2.3 触发器(Trigger)与碰撞(Collision)的本质区别

Collider上有个Is Trigger勾选项。勾上之后,这个碰撞体不再产生物理阻挡,只会在其他碰撞体进入、停留、离开时回调OnTriggerEnter/Stay/Exit。不勾则是实体碰撞,回调OnCollisionEnter/Stay/Exit。

两者的关键差异在于:Trigger回调不携带碰撞冲量信息,Collision回调携带ContactPoint和冲量。所以需要做“撞击力度判定”(比如撞击伤害、破碎效果)时必须用Collision;只是做“进入区域检测”(比如拾取、传送门、伤害区域)用Trigger更轻量。

还有一个容易忽略的点:Trigger事件要触发,至少一方必须有Rigidbody。两个纯静态碰撞体(都没刚体)之间,即使勾了Trigger也不会触发回调。这是新手最常见的“触发器不响应”原因,我在排查章节会再强调。

另外,Trigger的Stay回调是每物理帧都调用的,如果里面做了重逻辑(比如每帧查找组件、每帧实例化),性能会很难看。我的习惯是Stay里只做状态标记,真正的处理放到Update或者用计时器节流。

2.4 射线检测与形状投射:精确查询的两种武器

射线检测(Raycast)是物理查询里用得最多的工具,从枪械命中判定到地面检测、视线遮挡都靠它。核心API是Physics.Raycast,参数里最容易被忽视的是layerMask和QueryTriggerInteraction。

layerMask决定射线只和哪些层交互,QueryTriggerInteraction决定是否忽略触发器。默认情况下Raycast会命中触发器,如果你的场景里有大量触发区域,射线可能打在这些看不见的触发器上,导致“明明对着墙却打不中”。我一般显式传QueryTriggerInteraction.Ignore,除非确实需要检测触发区。

形状投射(SphereCast、BoxCast、CapsuleCast)是射线的“加粗版”,适合检测有体积的物体,比如角色胶囊体的移动检测。SphereCast的一个经典坑是:当球体起始位置已经和碰撞体重叠时,它可能检测不到,需要配合Physics.OverlapSphere做前置判断。这个细节在角色贴墙移动时特别容易暴露。

// 地面检测的典型写法,注意layerMask和触发器忽略 public bool CheckGrounded() { float rayLength = 0.1f; Vector3 origin = transform.position + Vector3.up * 0.05f; return Physics.Raycast(origin, Vector3.down, rayLength, LayerMask.GetMask("Environment"), QueryTriggerInteraction.Ignore); }

上面这段代码里,射线起点稍微抬高避免从碰撞体内部发出,长度0.1米对应角色脚底到地面的容差,层掩码只检测环境层。这几个细节都是实际调出来的,直接抄能少走弯路。

3. 实操过程与核心环节实现

3.1 从零搭建一个稳定的角色物理控制器

我按实际搭建顺序走一遍。第一步,角色根节点挂Rigidbody,Mass设70(人形量级),Drag设0,Angular Drag设0.05,Use Gravity勾上,Interpolate选Interpolate,Collision Detection选Continuous,Constraints里冻结X、Z轴旋转(防止角色摔倒翻滚,除非你要做布娃娃)。第二步,挂Capsule Collider,高度和半径按模型调整,注意胶囊体的中心点要对齐角色脚底到头顶的几何中心,否则会出现“脚陷进地面”或“悬空”的视觉问题。

第三步,移动逻辑放在FixedUpdate里,用Rigidbody.MovePosition或者直接改velocity。我倾向改velocity,因为MovePosition是运动学式的位移,会绕过部分物理响应。水平移动用velocity的XZ分量,垂直方向保留物理引擎算的重力,这样跳跃和下落才自然。

void FixedUpdate() { Vector3 move = new Vector3(inputX, 0, inputZ).normalized * moveSpeed; Vector3 vel = rb.velocity; rb.velocity = new Vector3(move.x, vel.y, move.z); }

这段代码的关键是保留vel.y,只覆盖水平速度。如果直接rb.velocity = move,重力就被抹掉了,角色会悬浮。这个坑我见过太多人踩。

第四步,地面检测用上一节的Raycast写法,配合Coyote Time(离开平台后的短暂容错跳跃窗口)提升手感。Coyote Time的实现就是记录最后一次着地时间,跳跃时判断当前时间减去它是否小于阈值(比如0.1秒)。

3.2 高速投射物的碰撞检测配置与穿透规避

子弹、箭矢这类高速小物体是穿透问题的重灾区。我的标准配置流程:Rigidbody的Collision Detection设为Continuous Dynamic,Mass设很小(0.01),Drag设0,Use Gravity按需。Collider用Sphere或Capsule,半径不要太小(太小更容易穿透),一般0.05到0.1米。

即便如此,极高速(比如每秒100米以上)的投射物仍可能穿透。这时候补一条射线检测:在发射时从起点到终点做一次Raycast,如果命中就直接结算伤害,不等物理碰撞。这叫**hitscan(即时命中)**方案,很多射击游戏对子弹就是这么处理的,物理子弹只做视觉表现。

// 发射时的即时命中检测 if (Physics.Raycast(muzzle.position, direction, out RaycastHit hit, range, LayerMask.GetMask("Enemy", "Environment"), QueryTriggerInteraction.Ignore)) { // 结算命中 }

实测下来,hitscan加物理子弹视觉的组合,既保证了命中判定准确,又有飞行过程的观感,是目前比较稳的方案。纯物理子弹在高速场景下永远有穿透风险,别指望调参数能100%解决。

3.3 触发器区域的完整实现:拾取、伤害区、传送门

以拾取物为例走一遍。拾取物挂Collider并勾Is Trigger,挂一个脚本处理OnTriggerEnter。玩家角色本身有Rigidbody和Collider,所以触发条件满足。脚本里判断进入者的Tag或Layer,然后执行拾取逻辑并销毁自身。

void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { // 加分数、播放音效、销毁 Destroy(gameObject); } }

伤害区稍微复杂一点,因为需要“持续伤害”。用OnTriggerStay配合计时器,每0.5秒结算一次伤害,而不是每物理帧都结算。这里有个细节:OnTriggerStay在物体静止时也可能不被调用(物理引擎的休眠机制),所以伤害区最好给进入的物体一个“唤醒”处理,或者用OverlapBox定期检测替代。我实际项目里更倾向后者,因为可控性更强。

传送门则是Enter时把玩家位置瞬移到目标点,注意瞬移后要重置刚体速度,否则玩家会带着进入时的速度飞出去。瞬移用rb.position赋值,然后rb.velocity = Vector3.zero。

3.4 物理关节与约束的实战用法

关节(Joint)在需要“连接”关系时很有用。Fixed Joint把两个物体焊死,Hinge Joint做门和摆锤,Spring Joint做弹簧。我用得最多的是Hinge Joint做可开关的门:门板挂Rigidbody和Hinge Joint,connectedBody设为门框(或留空连到世界),设置好锚点和轴向,然后通过motor驱动或者直接施加力来开关。

关节的坑主要在锚点(Anchor)和连接轴(Axis)的坐标系上。锚点是相对于自身Rigidbody的局部坐标,轴向也是局部方向。设错了表现就是门绕着奇怪的轴转或者直接飞出去。我的调试方法是先在Scene视图里把Gizmo看清楚,锚点小球要落在铰链位置,轴向箭头要指向旋转轴方向,确认无误再运行。

Configurable Joint是万能关节,但参数极多,新手不建议一上来就用。需要复杂约束时,先用简单关节组合,实在不行再上Configurable Joint,并且逐个参数试。

4. 常见问题与排查技巧实录

4.1 碰撞不触发、触发器不响应的排查顺序

这是被问得最多的一类问题。我整理了一个排查顺序,按可能性从高到低:

排查项检查内容常见错误
刚体是否存在碰撞双方至少一方有Rigidbody两个静态碰撞体互撞,永远不触发
层碰撞矩阵两层之间的勾选是否打开新建层默认可能没勾选
Is Trigger设置触发器和实体碰撞的回调不同勾了Trigger却监听OnCollision
碰撞体尺寸碰撞体是否真的重叠碰撞体缩放为0或位置偏移
脚本挂载回调脚本是否挂在有碰撞体的物体上挂在子物体但碰撞体在父物体
休眠状态刚体是否处于休眠静止物体需要WakeUp

按这个表从上往下查,90%的问题能定位。我遇到过一个特别隐蔽的:碰撞体在子物体上,脚本在父物体上,OnCollisionEnter写在父物体脚本里,结果因为碰撞体不在同一物体上,回调根本没进父脚本。Unity的碰撞回调是发给碰撞体所在物体及其刚体所在物体的,跨层级时要理清归属。

4.2 物体抖动、穿模、卡墙的成因与解决

抖动通常来自三个原因:一是物理和渲染不同步,开Interpolate能缓解;二是碰撞体之间有微小重叠,物理引擎每帧都在“推开”它们,表现为高频抖动,解决方法是调整碰撞体尺寸留出间隙,或者用Physics.ComputePenetration做手动分离;三是多个力同时作用(比如重力加自定义力),需要检查力的叠加逻辑。

穿模分两种:高速穿透(前面讲过,用CCD和hitscan解决)和挤压穿透(两个物体互相挤压,把薄物体挤穿)。挤压穿透的根源是物理求解器的迭代次数不够,可以在Project Settings里调高Solver Iterations(默认6,复杂场景可到10到15),代价是性能。我做过一个堆叠玩法,物体堆高了就互相穿插,把Solver Iterations从6调到12后明显改善。

卡墙是角色控制器常见问题,本质是胶囊体和墙面碰撞后,水平速度被物理响应吃掉,角色贴着墙动不了。解决思路是用CapsuleCast预先检测移动方向,如果前方有墙就沿墙滑动(把速度投影到墙面切向),而不是硬顶。这个逻辑写起来不复杂,但能极大改善贴墙手感。

4.3 性能优化:物理开销从哪里省

物理性能优化我按收益排序:第一,减少动态刚体数量,能静态的就设Static,能休眠的就让它休眠;第二,简化碰撞体,Mesh换基本几何体;第三,用好层碰撞矩阵,关掉无关碰撞对;第四,控制Fixed Timestep不要盲目调小,0.02够用就别改0.01;第五,射线检测加layerMask,别让射线扫全场景。

还有一个容易忽略的点:OnTriggerStay和OnCollisionStay里的逻辑。这两个回调每物理帧都触发,如果里面有GetComponent、Find、实例化这类操作,开销会成倍放大。我的做法是把需要的数据在Enter时缓存下来,Stay里只做轻量判断。

实测数据参考:一个场景从200个动态刚体降到80个(其余改静态),物理耗时从每帧3.2ms降到1.1ms左右;再把Mesh Collider全换成Box,又降到0.6ms。这个量级在移动端很关键。

4.4 物理调试工具与可视化技巧

Unity自带的Physics Debugger(Window > Analysis > Physics Debugger)非常好用,能可视化碰撞体、接触点、休眠状态。排查“为什么没碰撞”时,先打开它看碰撞体是否真的存在和重叠,比盲猜快得多。

另外,OnCollisionEnter里打印contact points能快速定位碰撞位置:

void OnCollisionEnter(Collision col) { foreach (ContactPoint p in col.contacts) { Debug.DrawRay(p.point, p.normal, Color.red, 2f); } }

这段代码在Scene视图里画出碰撞点和法线,方向对不对、位置准不准一目了然。我调弹跳和斜面滑动时经常用这招,比看数字直观。

最后分享一个习惯:给所有物理相关的关键参数(质量、摩擦、弹性、检测模式)在Inspector里加Tooltip注释,写清楚为什么设这个值。项目做大了之后,回头调参时这些注释能救命,尤其是多人协作时,别人一看就知道这个0.9的弹性是有意为之还是随手填的。

物理系统和碰撞检测这块,说到底就是“理解引擎的时序和规则,然后在规则内做配置和补充”。规则本身不复杂,复杂的是各种边界情况和组合效应。我个人的体会是,遇到物理问题先别急着改代码,打开Physics Debugger看一眼实际状态,往往问题就暴露了。参数调优没有银弹,都是算清楚单步位移、量好碰撞体尺寸、配好层矩阵,一步步试出来的。