做XR开发系列的项目,避不开碰撞检测这一环。不管你是做VR里的手势抓取,还是AR里的物体摆放,碰撞检测都像空气一样无处不在,但真正把它吃透的人,其实不多。这次我用一个特别小的案例——“当小球遇到收集物”来把碰撞检测这件事彻底掰开揉碎,从原理讲到踩坑,从方案选型讲到性能调优。这个小场景很适合XR开发入门练手,也很适合那些在Unity里写了不少交互逻辑、却总被碰撞问题折磨的朋友当作一次系统复盘。
这个案例本身很简单:场景里有一颗小球,周围散落着若干收集物,小球碰到收集物,收集物消失并加分。听起来是不是特别简单?但就是这个“简单”里藏着很多开发者容易忽略的细节——为什么有时碰撞不触发?为什么球直接穿过去了?XR里手柄手部应该如何参与碰撞?这些问题的答案,都能在这套小项目里找到。这篇文章我就完整记录一下我的实现过程,从环境准备到代码分析,再到问题排查,尽量做到你看完能直接照着复现。
1. 先把碰撞检测这件事想清楚
1.1 碰撞检测在XR世界里到底是什么角色
在Unity里说“碰撞检测”,其实绝大多数时候说的是两件事:一是物理引擎对两个物体是否接触的判定,二是碰撞发生后你要执行的游戏逻辑。前者靠的是Collider(碰撞体)和Rigidbody(刚体),后者靠的则是像OnTriggerEnter、OnCollisionEnter这类事件回调。
XR场景里这一环尤其重要,原因很简单:AR和VR里玩家会用手去“摸”东西、用眼睛去“看”准星,如果你的碰撞体位置不对、层级过滤搞错了,会出现特别出戏的体验——明明手都穿过苹果了,苹果却纹丝不动,这种违和感会瞬间把沉浸感毁掉。
打个比方,Collider就像快递箱外面的尺寸数据,告诉系统“我占多大地方”;Rigidbody则像箱子里的货物属性,告诉系统“我多重、能不能被推动、是不是会飘”。没有尺寸数据的箱子,快递分拣根本不知道该把它送哪;没有货物属性的箱子,就算占了位置也不会出现在分拣流程里。所以碰撞检测的第一步,不是写代码,而是确保物体上挂对了这些基础组件。
很多人喜欢一上来就闷头写脚本,结果发现碰撞永远不触发,回头一查,好家伙,两个物体上都没有Rigidbody。这种现象在XR开发者里实在太常见了,尤其是从纯美术、策划转过来的朋友,撞几次墙之后才真正理解物理引擎的工作方式。
1.2 为什么拿“小球+收集物”开刀
之前带项目的时候,我经常用这个案例作为XR开发的第一个实战练习,原因有三个。
第一,小球是规则几何体里最简单的一种,Sphere Collider 的包围逻辑最直观,不会像MESH Collider那样出现“怎么又穿模了”的排查地狱。用小球做碰撞实验,可以把变量控制到最少,出问题时一眼就能定位是物理参数的问题,还是脚本逻辑的问题。
第二,收集物这个玩法在XR里太常见了,项目里叫法五花八门:金币、能量块、数据碎片、记忆碎片。但底层的交互模式是一样的——玩家靠近,触发事件,物品质变或消失。我把这个小玩法吃透,以后做起各类收集任务、补充弹药、拾取装备,代码骨架基本可以直接复用。
第三,这个小案例能自然引出碰撞检测里的两个分支:触发器方案和物理碰撞方案。小球碰收集物,你既可以让收集物变成可穿透的“触发器”,用OnTriggerEnter处理收集;也可以让它保持物理碰撞,用OnCollisionEnter判断“被撞到”然后响应。两者各有合适的应用场景,用这个案例来对比演示,刚刚好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目准备与环境搭建
2.1 工具选型与版本选择
这次我用的是Unity 2021.3 LTS,搭配内置的XR Interaction Toolkit,相关版本是2.3.x。之所以选Unity而不是其他引擎,一是团队现有积累基本都在Unity生态,二是碰撞检测相关的物理接口在XR开发里足够成熟,网上踩坑资料也多。如果你用Unreal Engine,思路完全一致,只是节点名称和配置入口不同,比如碰撞通道Collision Channel的设置位置有些差异,但底层道理是相通的。
用Unity的话,记得在Package Manager里安装XR Plugin Management,同时根据你的目标设备安装对应的插件,比如Oculus XR Plugin、OpenXR Plugin或者AR Foundation。这一步别跳过,否则后面打包到真机时会发现很多现成的XR手势、射线组件根本调不出来。
有一点要重点提醒:只有编辑器里的场景,碰撞检测是能跑通的,但到了真机上,XR设备的注视点渲染、手柄控制器、空间定位都可能影响物理参数。所以从项目最开始就按真机环境来配置输入和交互,别等开发到一半才去适配。
2.2 场景对象与物理参数准备
场景搭建很简单,核心对象就三个:玩家操控的小球、地面、若干收集物。我习惯把地面做成长宽各20米的Cube,厚度0.2米,放在坐标原点附近;小球是半径0.25米的Sphere,挂在玩家控制脚本下;收集物我也用Sphere,只是半径小一点,比如0.15米,方便和玩家小球区分。
物理参数这一块是很多人头疼的重点。小球必须挂Rigidbody,这是和物理世界产生交互的前提;是否使用重力视玩法而定,如果希望更“街机”一点,可以关闭Use Gravity,加一个恒定的下压力和阻力,让小球运动起来更顺滑。我一般会给小球设置Mass=1,Drag=0.05,Angular Drag=0.1,Collision Detection改成Continuous,避免快速移动时穿透收集物。
收集物这边,如果用触发器方案,就把它的Collider勾选Is Trigger;如果用物理碰撞方案,就保留实体Collider,并根据需不需要被撞飞来决定加不加Rigidbody。如果只是收集,不加Rigidbody是更纯粹的方案,因为物理引擎只需要处理一次碰撞事件,少一个刚体就少一笔计算开销。
这些数值都不是乱拍的。Mass影响碰撞后的动量交换,Drag影响小球减速快慢,Continuous检测模式则是在告诉引擎:这个物体移动速度可能很快,请用连续检测来防止穿透。理解每个参数为什么这样设置,比死记数值重要得多。
3. 两种核心实现方案怎么选
3.1 触发器方案:轻量、适合“路过即收集”
触发器方案是XR收集类交互里用得最多的一种,核心思路是:收集物不参与物理阻挡,它更像一个“感应区”,玩家一经过就触发收集事件。
在Unity里,做法是给收集物的Collider勾上Is Trigger,然后挂一个脚本,在OnTriggerEnter里判断碰撞进来的对象是不是玩家。注意,OnTriggerEnter的回调参数类型是Collider,不是Collision,这两个很容易搞混,我见过不少新手在这上面报了“方法名没错但就是不触发”的错。
触发器的优势很明显,它不会影响小球的运动轨迹,收集物对玩家来说就是“可以穿过”的存在,不会因为一个金币挡在路中间就把球卡住。这在XR里特别重要,因为VR里的虚拟手持物只要稍微阻碍一点移动,玩家就会感受到明显的卡顿和反直觉。XR里收集物通常不是实体障碍,而是奖励物体,用触发器来处理非常合适。
缺点是触发事件本身不代表“物理接触”,所以如果你想让碰撞发生时有一定力度反馈、或者收集物被撞飞,触发器方案就不是第一选择。另外,触发器不会阻挡刚体运动,这在需要扮演“障碍”的场景里反而不合适。
3.2 物理碰撞方案:更真实,但开销也更明显
物理碰撞方案走的是OnCollisionEnter,参数类型是Collision,它会在两个带有Collider的物体真实接触后触发。比如小球弹到墙上,想做出反弹效果、想给玩家一个冲击反馈,就得用这个方案。
但注意,OnCollisionEnter至少需要其中一个物体带Rigidbody,否则引擎根本不会计算碰撞事件。两个物体都是纯Collider的话,碰撞检测只会产生物理阻挡,但不会给你回调事件。这个坑我踩过不止一次,调了半天代码,最后发现是缺了Rigidbody。
物理碰撞方案适合做“实打实”的碰撞反馈,比如小球要撞到某个机关才能开门、收集物是易碎品会被撞碎。这时候不仅有事件触发,还有碰撞点、碰撞法线、相对速度这些数据可以用来做精细反馈。相对速度算出冲击力,配合ParticleSystem和音频,可以做出很扎实的手感。
两种方案可以并行使用。比如收集物本身是实体障碍,但靠近的时候可以被拾取,那就要考虑用碰撞检测判断是否被撞到,再用触发器判断玩家是否伸手拾取。这也是很多游戏里“既能撞到又能捡起来”的物体的常见实现。
3.3 碰撞矩阵与层过滤:性能的隐形杀手
讲了两种方案后,必须单独说一下碰撞矩阵。Unity的Project Settings -> Physics里有一张碰撞矩阵图,横竖都是Layer(层),勾选的格子决定哪两层之间会发生碰撞。默认状态下所有层之间都能碰撞,这在项目简单时没问题,但XR场景一旦复杂起来,每帧的碰撞对数量会指数级增长,性能自然就崩了。
我的习惯是项目一开始就建好“Player”“Collectible”“Environment”“Interactable”这几个层,然后到碰撞矩阵里手动取消不需要碰撞的层对。比如,收集物之间的碰撞如果不影响玩法,就取消掉;Environment层虽然要参与阻挡,但不需要关心它和Collectible之间的碰撞。
这一步的收益在真机上非常明显,尤其是Quest这类移动平台上,每一笔物理计算都可能是帧率杀手。我在一个测试场景里把不必要的碰撞对关掉后,物理耗时下降了三成左右,而且代码完全没动。
4. 实操过程:从空场景到“吃”掉小球
4.1 搭一个能跑的XR基础场景
开一个空的3D项目后,我习惯先搭地面和灯光。地面Cube加Box Collider,材质用纯色,不要用默认的Standard Shader,直接在URP或HDRP管线里建一个Lit材质,颜色偏柔和,这样在头戴显示里头不会太晃眼。
接着创建小球,挂Rigidbody。如果后续要实现手柄抓取,还需要给小球加XR Grab Interactable组件;对应地,在XR Origin或Camera Offset上配置XR Ray Interactor,让手柄能发出射线拖拽小球。这一步在XR开发里属于标配,具体路径是GameObject -> XR -> XR Origin。
收集物的话,我建议先摆5个,不要一次性摆100个。先跑通最小闭环,再考虑规模。每个收集物挂一个Collectible脚本,标签(Tag)设置为“Collectible”,方便代码里通过CompareTag快速判断。
4.2 编写收集逻辑脚本
这里的核心脚本非常简单,但我会把细节讲透,因为它引出了一个很关键的架构问题:收集逻辑应该挂在收集物上,还是挂在玩家身上?
我推荐挂在收集物上,因为这样每个收集物可以有自己的行为,比如加多少分、播放什么音效、要不要生成粒子效果。玩家脚本只需要维持一个总分,收到消息后累加就行。如果反过来,玩家脚本要做一大段判断,后面加新的收集物类型时,玩家脚本会越来越臃肿。
csharp复制using UnityEngine;
public class Collectible : MonoBehaviour
{
public int scoreValue = 1;
public AudioClip collectSound;
public GameObject collectEffect;
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
Collect();
}
}
private void Collect()
{
if (collectSound != null)
AudioSource.PlayClipAtPoint(collectSound, transform.position);
if (collectEffect != null)
Instantiate(collectEffect, transform.position, Quaternion.identity);
GameManager.Instance.AddScore(scoreValue);
Destroy(gameObject);
}
}
GameManager是一个简单的单例,负责维护全局分数。如果你项目里还没有GameManager,可以先用一个公开静态字段代替,但长远看还是建一个专门的Manager类比较好,后面做存档、UI刷新都方便。
csharp复制public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
public int Score { get; private set; }
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
public void AddScore(int amount)
{
Score += amount;
// 在这里触发UI刷新、成就检测等
}
}
注意,OnTriggerEnter的命名和参数类型不能写错,参数是Collider,不是Collision,编译器不会报错,但Unity不会自动调用它。这是触发器方案里最常见的“假成功”问题:脚本写得很顺,运行起来毫无反应,结果就是参数类型不对。
4.3 跑通最小闭环并迭代手感
第一次Play时,我会故意把收集物放在小球运动路径的正中间,确认小球一碰到就消失、分数增加。这个过程看起来很初级,但很有必要,它能排除掉“场景开了但物体不在同一物理平面”这种低级问题。
跑通后开始优化手感:先关闭收集物的Is Trigger,改成物理碰撞方案,再试着让小球加速冲过去,看会不会穿透。如果穿透了,就把小球的Collision Detection改成Continuous,同时把Fixed Timestep从0.02秒调整到0.01秒,但要注意这会让物理计算量翻倍,移动端慎用。
然后我给反馈加细节:收集物消失时放一个粒子特效,同时播放短促的提示音,玩家的分数UI也做一个缩放的动画。这些反馈在XR里尤其重要,因为戴着头显时,音效和粒子是玩家唯一能直接感知到的“手感”。
4.4 XR环境里的特殊处理节奏
到了这一步,要把场景从普通3D转成真正的XR交互,有几个点必须处理。
第一,玩家不能是一颗浮空的小球,而是需要换成XR Origin里的Camera Rig。让小球跟着头显移动,或者用手柄控制器控制一个虚拟小球运动。用XR Ray Interactor可以让玩家用手柄发射一条射线,射线指点即可让小球向目标点移动。
第二,手部模型和碰撞体的问题。XR里头显里能看到虚拟手,但如果手的Collider没有被正确配置,玩家用手去“碰”收集物时,碰撞事件可能发生在肩膀位置而不是指尖位置,体验会非常奇怪。我通常会在每个手指骨骼上挂小的Sphere Collider,或者在手掌上配置一个较大的球形碰撞体,并把它放在专用的“Hand”层里。
第三,XR延迟会暴露碰撞参数问题。在PC编辑器里觉得手感正常的参数,到真机上一跑,可能因为帧率波动导致小球速度毛刺,碰撞检测会不稳定。真机测试是XR碰撞调优不可跳过的一环,别偷懒。
5. 常见问题与排查技巧实录
5.1 碰撞就是没触发,从哪里开始查
这个问题在社区里被反复提问,我直接做一个速查表,方便你对照排查。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| OnTriggerEnter不调用 | 两个物体都没有Rigidbody | 给任意一个物体加Rigidbody |
| OnTriggerEnter不调用 | 用成了OnCollisionEnter的签名 | 检查参数是不是Collider |
| 碰撞事件不调用 | 碰撞矩阵里对应层被取消了 | Project Settings -> Physics里恢复勾选 |
| 事件触发了但没效果 | 标签(Tag)比较失败 | 检查收集物的Tag是不是“Collectible” |
| 脚本不运行 | 脚本挂在Collider所在物体的子物体上 | 事件回调只会发挂在同一物体上的脚本 |
很多“不触发”问题的本质是回调没有被引擎正确识别。Unity按名称反射调用这些方法,任何一点一个小小的不匹配都会导致方法被忽略,而且不报错。所以排查这类问题时,第一件事就是在方法里加一行Debug.Log,看看是不是根本没走到。
5.2 小球穿过收集物,像变成了幽灵一样
穿透问题在高速移动的球体上特别常见。默认的Discrete(离散检测)模式是每隔一个物理帧采样物体位置,如果物体移动速度快,两个采样点之间的距离超过了收集物的尺寸,引擎就会认为“上一帧没碰,下一帧已经过了”,于是完美错过。
解决办法是切换Collision Detection Mode。Rigidbody的检测模式有Discrete、Continuous、Continuous Dynamic和Continuous Speculative几种。我对小球这种高速移动物体会使用Continuous,它会把运动路径当作连续线段检测,基本能避免穿透。但代价是计算开销更高,所以只给可能高速运动的物体开就行,别全场都开。
如果开了Continuous还是穿透,那就得怀疑是不是Fixed Timestep太小。Unity物理更新频率默认每秒50次,间隔0.02秒。在压力很大的XR场景里,物理帧率可能不稳定,这时可以试试把Fixed Timestep调到0.01秒,物理更细腻,但要评估性能。
另外,小物体撞大物体时,建议把大的物体放进一个空父物体里,空物体挂Rigidbody,然后在子物体上放Collider。这样物理引擎会按父物体做整体计算,比自己手动去拼凑碰撞体省心很多。
5.3 性能抖动与GC分配
随手写收集逻辑很容易埋下性能隐患,X R场景里尤为明显。最常见的问题是每碰撞一次就Instantiate一个粒子特效,然后不主动回收,对象池没做,GC压力飙升。
我的建议是,在GameManager里维护一个粒子特效对象池,提前预创建几个特效实例,碰撞时从池里取,播放完后回收。代码上其实不复杂,就是Queue
另一个容易被忽略的问题是AudioSource.PlayClipAtPoint每帧如果频繁调用,会导致大量GC。更好的做法是在收集物上挂一个AudioSource,把PlayOneShot作为备选方案,或者在全场景里维护一个公共的音频管理器,统一控制音效的播放。
5.4 XR特有的一些“灵异事件”
有个现象我之前研究了好久:玩家手柄一靠近收集物,收集物虽然消失了,但玩家手部的碰撞体也会被跟着销毁。这是因为收集物的OnTriggerEnter里用了“other.CompareTag("Player")”,而手部控制器上正好也挂着Player标签,于是误判了。
解决方案是把手部和玩家的主碰撞体放到不同层,然后在条件判断里加一层层级校验,只有特定层才触发。XR的交互层级比普通3D项目复杂得多,如果你一个Player标签管到底,后面一定会遇到各种“误碰撞”。
6. 调试碰撞问题的独家心得
调试碰撞,最推荐的工具就是Unity自带的Physics Debugger。Window -> Analysis -> Physics Debugger窗口里能看到每个碰撞体、刚体的实时状态,点开每个物体还能看它的检测模式、速度、是否在休眠。我每次遇到“肉眼判断碰撞了但逻辑没反应”的时候,都会先开这个窗口看一眼,比反复猜测高效十倍。
另外一个小技巧是,在碰撞事件里把碰撞点的位置画出来,方便快速定位问题。用Debug.DrawLine或者OnDrawGizmos来可视化,可以把碰撞真正发生的位置直观地显示在Scene视图里。比如收集物明明在左边,但碰撞点显示在右边,那就说明Collider偏移了,需要调整中心点。
我还有一个习惯,是给所有碰撞相关的物理层命名时,在名称后面标注用途,例如“Player_Hand”“Collectible_Pickup”“Environment_Static”。这样在多人在线项目或团队协作时,别人一看层名就知道这个层承担什么职责,而不是看着“Layer8”“Layer9”一头雾水。这个习惯帮我避免过很多次因为层设置混乱导致的碰撞冲突。
XR开发里碰撞检测这关,说到底是“基础却绕不开”的命门。小球遇到收集物只是一个很小的切面,但把这一套思路摸透,后面面对NPC交互、物体抓取、物品堆叠,你就会发现所有场景里的碰撞逻辑都有相似的内核:理清物理引擎的触发条件、规划好层与标签、设计好事件触发后的反馈路径。希望这次的拆解能帮你少踩几个坑,至少在“为什么没反应”“为什么穿模了”这些经典问题上,能更快找到方向。
