做物理模拟这几年,我最怕听到的不是“引擎崩了”,而是“子弹怎么又从墙上穿过去了”和“角色怎么卡进地板里了”。这两个问题十有八九都出在碰撞检测这一层,更准确地说,是高级碰撞检测没有配好。PhysX 3.4 文档里《Advanced Collision Detection》这一章内容不算多,但信息密度极高,几乎每个函数、每个参数都对应着真实项目里的一个坑。这篇文章不打算逐行翻译文档,而是把我自己从读文档到落地实现、再到调优踩坑的完整过程整理出来。如果你正在做游戏物理、刚体仿真、或者任何用到 PhysX 3.4 的项目,看完应该能少走几条弯路。
1. 高级碰撞检测是干什么的:先理清那两条流水线
1.1 碰撞检测不是“检测”这么简单
很多同学第一次接触 PhysX 时,以为碰撞检测就是“两个物体碰到了一起就回调一下”。真正把项目跑起来之后才发现,引擎里其实有两条完全独立的“检测通道”,一条是模拟管线里的碰撞检测,另一条是场景查询(Scene Query)。
模拟管线里的碰撞检测,负责的是刚体之间的真实接触。它又分成两个阶段:Broadphase 和 Narrowphase。Broadphase 做的事非常粗糙,就是把所有物体的包围盒(AABB)互相比较,找出“有可能碰到的候选对”;Narrowphase 才对候选对做精确的形状相交检测,生成接触点、法线、穿透深度,交给求解器去算。
场景查询则是另一码事,它是给游戏逻辑用的。你要检测“这条射线打中了谁”、“这个扇形区域里有哪些物体”、“这个胶囊体往前推会不会撞到墙”,用的都是场景查询,而不是把临时刚体丢进物理世界。
《Advanced Collision Detection》这章的精髓,就是教你怎么把上面两条流水线都调明白。它的内容可以粗略分成四块:碰撞过滤(Filtering)、连续碰撞检测(CCD)、场景查询的高级用法、以及特殊几何体(比如地形网格)的碰撞处理。我按这几个方向逐个说。
1.2 为什么默认配置“看起来能用,一上量就废”
PhysX 3.4 的默认参数在设计上是偏保守的,它优先保证“不出错”,而不是“高性能”。默认情况下,接触偏移(contactOffset)很小、CCD 是关闭的、场景查询也没有任何自定义过滤,所有逻辑都靠引擎内置规则跑。
这种配置跑一个小 Demo 完全没问题。但一旦场景里有几十个高速飞行的炮弹、一个需要精确感知周围环境的 AI、或者一块非常大的地形网格,默认配置就会暴露出三个问题:
- 第一,漏检。高速物体在离散时间步里跨过了薄墙,物理引擎永远不知道它们曾经“穿过”。
- 第二,误报。接触偏移设置不当,物体还没真正贴到墙,就被接触信息提前催生了一堆错误力,表现就是角色“粘墙”或者布娃娃在空气里乱飘。
- 第三,性能失控。没有过滤机制时,Broadphase 之后产生的候选对可能非常多,Narrowphase 再逐个精确计算,帧时间一下就顶满了。
高级碰撞检测这部分文档的价值,就在于它告诉你每个参数背后对应什么成本,什么情况下该开什么开关。我分开细聊。
2. 碰撞过滤(Filtering):不只是杀 Pair 那么随便
2.1 Filter Shader 和 Filter Callback,什么时候该用谁
PhysX 3.4 的碰撞过滤体系有两个核心角色:Filter Shader 和 Filter Callback。很多教程把它们统称为“过滤函数”,其实两者机制完全不同,用错了会影响正确的碰撞结果,甚至拖垮性能。
Filter Shader 是同步执行的,它跑在 Narrowphase 做精确相交计算之前。引擎在 Broadphase 阶段发现一对可能碰撞的形状之后,会立刻调用 Shader 做一次“预审”。Shader 的返回值决定这对形状是彻底忽略(eKILL)、直接参与碰撞生成接触点(eDEFAULT)、还是调用更重的后置回调(eCALLBACK)。
你可以在 Shader 里传入自定义的 FilterData(通常是一个包含类型、分组信息的结构体),然后在 Shader 函数里读取它,进行位运算判断。我的习惯是尽量在 Shader 里完成所有“一刀切”的决策,因为它便宜、同步、可预测,而且不会产生额外的调度开销。
Filter Callback 则不同,它是异步回调,在接触点已经生成之后才被调用。这个回调里你能拿到的信息更完整,可以修改接触点、法线方向,甚至在最后一刻把接触流整个掐掉。它适合做“需要精确几何形状参与判断”的过滤,比如:只有子弹击中敌人头部才造成暴击、只有物体正面碰撞才触发机关。
我见过一个项目,把所有过滤逻辑都塞进 Filter Callback,结果同一帧碰撞回调数量爆炸,主线程被拖死。正确做法是:能用 Shader 做的粗筛,绝不留到 Callback 里做。Shader 负责把不相关的 pair 批量干掉,Callback 只处理剩余少量需要精细决策的接触。
2.2 Contact Offset 和 Rest Offset:两个参数决定手感
说到接触生成,就绕不开 physx 里两个形影不离的参数:contactOffset和restOffset。这两个参数挂在 Shape 上,很多人直接忽略,但它们直接影响一切碰撞判定。
contactOffset是接触生成的“提前量”。当两个形状的距离小于 contactOffset 时,引擎就认为它们处于接触状态,开始生成接触点。它相当于一个吸力范围,太小了物体容易高速穿透,太大了物体会在还没贴合时就互相排斥或者抖动。
restOffset是休息距离。当两个形状达到 restOffset 这个距离时,接触力会趋于零,物体自然静止。它的意义在于:让两个紧贴的物体之间留出一层极小的缝隙,避免接触点反复交替导致“抖动”。
我给一套实际项目里常用的初始值:contactOffset用 0.02 左右(以米为单位,如果你的游戏尺度是 1 单位 = 1 米),restOffset用 0.001。注意restOffset一定要小于contactOffset,否则物理世界会一直产生推斥力,表现为物体之间的“静电反弹”。
2.3 过滤逻辑里最容易踩的三个坑
第一个坑:在 Shader 里做重量级逻辑。Shader 的调用频率极高,每个候选对都会触发。你千万别在里面做字符串匹配、访问外部对象池、或者查很重的配置表。Shader 应该是纯函数式逻辑,最好只做位运算和整型比较。
第二个坑:对 Trigger(触发器)也用 eKILL。触发器靠 eNOTIFY_TOUCH_FOUND 这类事件通知来工作,如果你在过滤阶段直接把 pair 杀掉了,那么触发器的进入/离开事件永远不会触发。真实表现就是:玩家明明走进了机关区域,机关却纹丝不动。
第三个坑:忘了 constantBlock 参数。PhysX 允许你在两个角色间传递一份共享数据块(constantBlock),很多过滤规则是可以预计算好塞进这个 block 的,但项目里经常出现“每次 Shader 都重新算一遍”的浪费写法。把常用规则预计算后塞进去,可以白捡不少性能。
3. CCD(连续碰撞检测):别再让子弹穿墙了
3.1 先理解为什么物体会穿透
物理引擎默认是“离散碰撞检测”。它把时间切成帧,每一帧检测一次碰撞。如果一帧内某个物体的位移超过了薄物体的厚度,引擎就会漏掉碰撞:上一帧子弹还在墙左边,下一帧子弹已经跑到墙右边了,中间没有任何一帧检测到相交。
要解决穿透,思路就是“连续碰撞检测”:不是等物体到了新位置再去检测,而是预测物体从旧位置到新位置的过程中,扫过的体积有没有撞到东西。PhysX 3.4 在这方面提供了两类做法,一类是“线性扫掠”(把形状沿着运动轨迹扫过,检测扫掠体与环境相交),另一类是“投机性 CCG”(提前预测接触点,扩大接触生成范围)。对应到引擎里,就是刚体标志位eENABLE_CCD和eENABLE_SPECULATIVE_CCD。
3.2 手动开启 CCD:该开谁、怎么调
在 PhysX 3.4 里,CCD 不是全局开关,而是挂在刚体上的一个标志位。你需要对指定的 PxRigidBody 执行:
body->setRigidBodyFlag(PxRigidBodyFlag::eENABLE_CCD, true);开启了之后,SSccd 也不是全部轨道都能扫,线性扫掠对快速物体与静态环境之间的碰撞效果很好,尤其是子弹、飞镖这种体积小、速度快的物体。但动态体与动态体之间的高速碰撞,线性扫掠的支持相对有限,这时候就需要投机性方案。
我的经验是:全场景压低数量,只给关键物体开 CCD。一个游戏里可能有几千个刚体,全开 CCD 会让性能雪崩。更合理的策略是:给玩家角色、重要 NPC、快速子弹单独开 CCD,普通小物件任其自生自灭,物理表现也不会差。
配套还要调一个东西:网格的精度。CCD 扫掠是拿形状的“扫掠体”去做碰撞,如果你的 Mesh 是那种极度细长的三角形网格,扫掠体相交计算的误差会放大,表现就是“偶尔还是会穿”。这种情况下用凸包替换三角网格,往往立竿见影。
3.3 开了 CCD 还是穿?从这三个方向查
最常见的情况是:CCD 开了,子弹还是穿墙。我排查这种问题,基本固定在三个原因上。
第一,速度还不够,但你要先看子弹每帧位移是不是已经超过了接触阈值。把contactOffset调大一点,让接触生成范围覆盖住“上一帧位置到下一帧位置”的轨迹。
第二,开启了eENABLE_CCD但没注意它和休眠状态的关系。物体进入休眠后不会参与任何碰撞检测,如果子弹明明没动却被判定为休眠,它自然就不检测了。你需要确认没有把PxActorFlag::eDISABLE_SIMULATION这类标志位错误地挂到要开 CCD 的物体上。
第三,滤波器把 CCD 接触给杀掉了。有些项目里 Shader 会把子弹与墙壁的 pair “干净利落地 kill”,用来避免子弹卡在墙里的表现,结果这个操作连 CCD 预测这个 pair 时也一并拦截了。
4. 场景查询(Scene Query):射线、扫描体、重叠检测的精细控制
4.1 一次说清 Raycast、Sweep、Overlap
场景查询三件套是物理引擎的日常武器。我做一个简单的横向对比表,方便你直接对着选型:
| 查询类型 | 用途 | 返回核心信息 | 适用场景 |
|---|---|---|---|
| Raycast(射线检测) | 从起点沿方向发射射线 | 命中点、法线、距离、形状对象 | 准星瞄准、视线判定、点击拾取 |
| Sweep(跳跃扫描) | 把一个形状沿方向移动,检测路径上的碰撞 | 最近命中点、距离、法线、形状对象 | 角色移动预判、推力检测、AI寻路避障 |
| Overlap(重叠检测) | 检查一个形状覆盖范围内有哪些对象 | 重叠的形状列表,无距离和法线 | 范围拾取、触发器检测、预警扫描 |
Raycast 适合“一根线”的问题,Sweep 适合“一块体积”的问题,Overlap 适合“一个区域里有什么”的问题。看起来简单,但选错的情况非常多。最常见的是用 Sweep 去做本来该用 Overlap 的区域检测,白白付出额外命中的计算成本。反过来,用 Raycast 去模拟 Sweep 也常见,比如把射线打成扇面发一堆,结果角度稀疏处漏检。
4.2 命中类型和查询 Buffer:别再把所有命中当一回事
PhysX 3.4 的查询结果分为 blockHit 和 touchedHits。blockHit 是最先阻挡查询的物体,touchedHits 是查询路径上经过的所有物体。你可以在查询标志里指定要收集哪些数据,关键标志包括距离、法线、面索引、冲击点等。
很多新手第一次用场景查询,会把返回的所有命中都当作“撞到了”。这里有个非常隐蔽的坑:Raycast 默认会把多个命中全部返回,你需要自己排序、自己判断法线朝向,尤其是射线从物体内部发射的时候,第一个触碰未必是你想要的那个“最近的阻挡”。我在做角色脚底探测时,就曾经把一个从内部穿出的 hit 当成地面支撑点,导致角色在半空中被“撑住”。
实操上,我建议返回数据直接用 PxRaycastBuffer、PxSweepBuffer、PxOverlapBuffer 这类专用缓冲区,而不是自己开裸数组。一方面代码更清晰,另一方面引擎内部可以复用内存,避免每帧都产生动态分配。如果你把查询封装在一个高频调用函数里,这个差异会非常明显。
PxRaycastBuffer hitBuffer; PxQueryFilterData filterData(PxFilterData(...), PxQueryFlag::eDYNAMIC | PxQueryFlag::eSTATIC); bool ok = scene->raycast(origin, unitDir, maxDist, hitBuffer, PxHitFlag::eDEFAULT, filterData); if (ok && hitBuffer.hasAnyHits()) { const PxRaycastHit& hit = hitBuffer.getAnyHit(); // 处理命中信息 }4.3 场景查询的性能:把每一毫秒都抠出来
场景查询的耗时会随着场景复杂度增长。我在项目里做过一轮优化,效果最明显的几个点按优先级排:
第一,用好过滤器。场景查询在过滤时,PxQueryFilterCallback 的 preFilter 阶段会先跑一遍,如果你在 preFilter 里快速 reject 掉 90% 不相关的对象,后续的窄相位检测量会骤减。这和碰撞过滤里的 Shader 哲学一模一样:粗筛尽量前置。
第二,用eANY_HIT模式。当你只需要知道“有没有命中”而不关心具体是哪个物体时,开启 anyHit 标志可以提前终止查询,不需要遍历整个 BVH 的所有可能节点。典型的用途是:角色能不能站到某个区域、AI 是否被障碍物挡住视线。
第三,尽量不要每帧在多个线程里乱发场景查询。PhysX 的场景查询线程安全性和版本有关,3.4 下最好把查询集中在固定的阶段执行,让引擎底层加速结构处于已知状态。跨线程高频查询带来的锁竞争,往往比查询本身还贵。
5. 高级形状的碰撞细节:地形、凸包、三角网格
5.1 Heightfield 地形:建的方便,用的讲究
地形碰撞在 PhysX 3.4 里最推荐的方案是 Heightfield(高度场)。它比三角网格地形快得多,内存占用也小。构建 Heightfield 的核心是把地形的高程数据烹饪成一个 PxHeightField 资源,再把资源挂到静态刚体上。
材料准备上,你需要按行存储的PxHeightFieldSample数组,里面填好每个点的高度值(short 类型)和材质索引。烹饪时要注意一个反直觉的细节:高度场默认是单面的,从“下方”看过去是透明的,碰撞可能不会发生。PhysX 提供了 thickness 参数,给地形加一层“厚度”之后,从地下往上撞也能触发碰撞。我的经验是 thickness 不要给 0,给一个略大于角色默认步进的值,能避免很多角色掉出地形的怪毛病。
另外还有 Tesselation 等级。高度场烹饪时会自动生成三角形网格,tessellation 级别控制网格的细分程度。这个值不是越高越好,因为物理网格越精细,Narrowphase 计算越贵。对于地形这类大体量静态物,我建议在精度满足要求的前提下尽量用低细分。
5.2 凸包与三角网格:物理引擎为什么偏心凸包
PhysX 的稳定碰撞实现里,凸包(Convex Mesh)有专门的快速算法,而三角网格(Triangle Mesh)只能做静态/运动学物体的碰撞,动态刚体无法直接使用三角网格作为碰撞体。这是很多人前期规划时忽略的硬约束。
如果你需要让一块复杂的动态物体既有好看的外观又有物理碰撞,正确的做法是给动态体配一个近似的凸包碰撞体,而不是把高模丢给它。凸包的优势在于:碰撞检测稳定、接触点数量可控、作为 CCD 扫掠体也快。
在烹饪三角网格时,PhysX 提供了网格焊接、清理退化三角形等一系列选项。我建议不要关掉清理选项,尤其不能忽略eWIND_COUNTERCLOCKWISE这类绕序设置。网格绕序反了会导致碰撞法线方向错误,表现就是物体“从上面掉不进地面,反而会被地面向外推”。
5.3 复合形状:动态体的组合碰撞体
一个动态刚体上可以挂多个 Shape,它们组合成一个复合碰撞体。这个机制非常实用,比如一个角色模型可以用胶囊体做躯干,再加几个球体做四肢的碰撞判定。
但复合形状也有一个常见的性能坑:形状之间如果互相重叠,Narrowphase 会产生大量重复接触对。PhysX 提供了PxShapeFlag::eSIMULATION_SHAPE这个标志位,可以精确控制哪个 Shape 参与模拟碰撞、哪个 Shape 只参与场景查询。合理组合这些标志,让复合体中的每个 Shape 各司其职,比单纯堆数量要高效得多。
6. 性能优化与排查工具:从“能跑”到“跑得稳”
6.1 休眠与接触管理是隐藏的优化开关
物理引擎的性能瓶颈往往不在 Narrowphase 本身,而在“管理了多少对接触”。场景里如果长期存在大量静止或低速物体,每一帧还在重复生成相似接触点,这非常浪费。
PhysX 的刚体休眠机制能自动让静止物体退出模拟,但前提是你没有不小心在每一帧给它们施加哪怕一点微小的速度扰动。我的排查习惯是:如果发现场景中大量物体不睡,先去看看是不是接触参数导致它们一直处于“微小抖动”状态。调高 restOffset、并把材质摩擦系数设成合理值,通常能让物体们安静下来。
另外还有一个容易被忽略的点:接触点报告的压力阈值。PhysX 支持设置接触力阈值,低于阈值的接触不会产生回调通知。如果你每一帧都收到一堆“接触点”但根本不用它们,记得用PxPairFlag::eNOTIFY_THRESHOLD_FORCE_FORCE这套机制做过滤,这样可以大幅减少回调处理开销。
6.2 用调试工具定位碰撞问题
PhysX 自带的 PVD(PhysX Visual Debugger)是我调试高级碰撞问题时的首要工具。它能实时显示场景里每个碰撞体的形状、过滤后的 Pair、以及场景查询的结果。当你觉得“碰撞结果不对”的时候,先开 PVD 看几何体是不是你想象中的样子,很多时候结论是:网格绕序反了、凸包比模型大了一圈、或者 contactOffset 画出来的“隐形范围”吞掉了旁边的物体。
PVD 里的可视化还能帮你判断 CCD 扫掠体的形状和扫掠长度,排查高速穿透问题的效率会高很多。如果条件允许,把目标物体的形状画出来对比实际模型,最容易发现“碰撞体和视觉不一致”的隐性 bug。
6.3 一份可复用的问题排查速查表
| 现象 | 优先怀疑 | 处理办法 |
|---|---|---|
| 高速物体穿过薄墙 | CCD 没开或开了仍扫掠失败 | 开启 eENABLE_CCD;检查 contactOffset;给物体换凸包碰撞体 |
| 物体轻微抖动无法静止 | restOffset 设置过大或接触力反复 | 下调 restOffset;检查是否每帧施加微扰动 |
| 触发器没有触发 | Filter 阶段 kill 掉了 pair | 检查 Shader 返回值;确认 eNOTIFY_TOUCH_FOUND 保留 |
| 地形从下方穿透 | Heightfield 厚度为 0 | 给厚度设置合理正值 |
| 射线命中位置不准 | 把 touchedHits 当 blockHit 用 | 确认查询模式,只拿最近阻挡命中 |
| 动态物体碰撞不稳定 | 使用了三角网格做动态体 | 换成凸包或复合形状 |
7. 结尾:一些真实的项目体会
把这套高级碰撞检测配置下来,最深的感受是:PhysX 3.4 的文档本身并不难懂,难的是把它放到真实项目里做选择。每个开关、每个偏移量都不是独立生效的,它们互相影响。频率最高的几个配置链路——过滤 Shader、CCD 标志位、场景查询缓存、接触偏移——我建议在做项目初期就定好默认策略,而不是等项目出了问题再逐个调。
我个人在实际操作中还有一个习惯:每次修改碰撞相关参数,都把修改记录和现象变化对应起来。穿模问题很多时候是“改了 A 修好了,换了 B 场景又出来了”,没有记录很难回头复盘。如果你也想找一套稳妥的上手路径,可以先严格按默认参数跑通一个新场景,然后只改一个参数、观察一次表现,这样一步步建立自己项目的“碰撞参数指纹”。
希望这篇基于 PhysX 3.4《Advanced Collision Detection》的实践梳理,能帮你把那些藏在文档角落里的关键设置用起来,让子弹稳稳地打在墙上,角色稳稳地站在地上。