
上个月有个做具身智能的团队找我做技术选型尽调他们的诉求很具体强化学习训练环境里一帧要跑几千个并联机械臂之前用某个自研的刚体求解器接触一多就抖训练曲线跟着一起抖。他们问的第一个问题不是PhysX 快不快而是NVIDIA PhysX 的源码我能不能拿来看懂、改得动、扛得住。这个问题其实比性能数字重要得多。我把 NVIDIA-Omniverse/PhysX 仓库整个拉下来从foundation一层层往上读到gpusolver又对照了Omniverse这一侧把 USD 翻译成物理场景的那套中间层前后花了三周。这篇就是我这份物理引擎源码尽调的完整笔记——它解决的是选型时怎么判断一个物理引擎能不能进你的生产链路适合正在做机器人仿真、数字孪生、自动驾驶闭环仿真的工程师和技术负责人看也适合单纯想搞明白商业级物理引擎内部长什么样的同学。1. PhysX 5 源码树的分层逻辑与构建链路上的坑1.1 七层目录本质上是三层依赖把仓库拉下来第一眼会觉得目录有点碎但理清依赖之后其实很干净。物理引擎这种东西的复杂度不在功能有多少而在哪一层可以单独替换、哪一层必须一起编译。PhysX 的目录划分基本就是照着这个边界切的目录职责能不能单独用source/foundation内存、容器、线程、SIMD、错误回调、profiler 钩子能纯工具层几乎不依赖别的东西source/physxcommon数学库、几何体、BVH、GJK/EPA、接触生成的底层算法能但要有 foundationsource/physxcore场景、刚体、形状、约束、求解器主循环必须配上面两层source/physxextensions关节辅助类、序列化、默认 CPU dispatcher、工厂函数可选很多团队只用 coresource/physxcooking凸包、三角网 BVH、高度场、软体的离线烘焙可选但离线管线基本绕不开source/physxvehicle/physxvehicle2两代载具 SDK前一代基于射线悬挂后一代是组件化重写可选source/gpu*CUDA 侧的求解器、宽相、关节、粒子、软体只有开 GPU 才编我特意强调一下foundation这层因为它是判断这个引擎好不好接手的第一指标。一个团队的源码能不能维护很大程度取决于它有没有把内存分配、断言、线程原语统一收口。PhysX 这里做得相当克制PxAllocatorCallback、PxErrorCallback、PxProfilerCallback三个接口几乎贯穿全局你要接入自己的内存池做统计或者把物理线程的耗时打进自家的性能平台只需要实现这三个回调传进PxCreateFoundation一行业务代码都不用改。这一点在自研引擎里往往是最容易失控的地方。提示接自己的 allocator 时记得对齐要求。PhysX 在 SIMD 路径上要求 16 字节对齐有些引擎的默认分配器只保证 8 字节跑起来在 AVX 相关分支上会直接崩而且崩的位置离真正的原因很远。1.2 构建入口packman 是第一个拦路虎PhysX 用 CMake但不是那种clone 完就能 cmake的仓库。它依赖 NVIDIA 自家的包管理器 Packman 去拉一批预编译依赖首次构建会去外网下载 blob。企业内网环境下这是第一个坑解决办法通常是先在能联网的机器上把PM_PACKAGES_ROOT指向的目录整体缓存下来再拷进内网构建脚本里把PM_PACKAGES_ROOT环境变量指过去。Linux 下的典型流程长这样export PM_PACKAGES_ROOT/opt/packman_cache # Linux 下先跑一遍工程生成脚本它会根据 preset 拉起 cmake ./generate_projects.sh linux cd compiler/linux-release make -j$(nproc)几个实测下来影响很大的点。第一预设文件在buildtools/presets/public/下面linux.xml里能看到PX_GENERATE_GPU_CODE这类开关想编 GPU 求解路径必须打开它同时机器上得有匹配版本的 CUDA Toolkit 和nvcc。第二编译时间远超预期开 GPU 代码之后在 32 核机器上也要二十分钟往上关掉 GPU 大概八到十分钟所以日常改业务逻辑建议直接用 CPU-only 的 preset 迭代。第三产物分为 CPU 侧静态库和 GPU 侧动态库两部分部署时 GPU 库要跟着可执行文件一起走容器镜像里漏拷这个.so是很常见的事故表现是创建 GPU dispatcher 时静默失败然后整个场景退回 CPU性能掉一个数量级但程序不报错。1.3 单位、坐标系和重力引擎不替你做世界约定有个新手很容易踩的坑写个 demo 发现物体不下落。PhysX 不预设世界朝向重力是场景描述符里显式给的字段轴向上是 Y-up 还是 Z-up 完全由宿主决定。Omniverse 那侧因为 USD 的约定用的一般是 Z-up而你从 PhysX 官方 snippet 抄来的例子是 Y-up两边混用的时候会出现机器人贴着墙面往下滑这种诡异现象。单位同理。PhysX 内部是米-千克-秒但精度上有个经验阈值你的最小几何特征尺寸如果小于 1 厘米接触生成的稳定性会明显下降因为默认的接触偏移和容差都是按米级场景调的。做微型机器人或者精细夹爪仿真的时候业界常见做法是把整个场景的尺度放大约 10 倍来算算完再把结果换算回去而不是去改引擎内部的容差常量。这个技巧文档里不会写但做精密操作仿真的团队基本都知道。2. PGS 与 TGS两个求解器在源码里的真实差异2.1 不是新旧关系而是两种时间步思路PhysX 从 4.0 开始提供了两套刚体求解器通过场景描述符里的求解器类型字段选择。网上很多文章把它说成TGS 是 PGS 的升级版这个说法会误导选型。PGS 是投影高斯-赛德尔迭代思路是把所有约束速度写成一个线性系统然后一轮一轮地扫每轮把单个约束上的误差投影掉。它的问题在于时间步长固定一个物体一帧移动得远一点接触就可能穿过去或者产生很大的冲量堆叠场景里下层物体被反复冲击就会表现为抖动和塌陷。TGS 是时间高斯-赛德尔核心区别在于它会把一个仿真步按物体的速度自适应地细分成若干子步接触约束建立在时间上而不是当前这一瞬间上。物体跑得越快细分得越细。代价是每个步都要做多次约束求解CPU 开销上去但堆叠稳定性和高速场景的表现提升非常明显。选型上我的建议很直接场景里主要是少量大物体、低速、碰撞关系简单用 PGS省 CPU。有大量堆叠、细小物体、高速抛射或者做抓取装配这类接触敏感的仿真用 TGS。求解器类型是建场景时固定的运行期不能切换所以别指望先 PGS 跑着后期再说。2.2 迭代次数、接触偏移和摩擦模型真正影响手感的三组参数迭代次数是所有参数里最容易被乱调的。它的两个分量分别控制速度求解和位置求解的轮数。经验上从默认值起步遇到堆叠发软就往上升一档但收益衰减很快从 4 提到 8 通常有肉眼可见的改善8 提到 16 改善有限而开销翻倍再往上基本是在浪费时间。真正该调的是下面这几个接触偏移与静止偏移这组参数决定了接触在什么距离上被建立。接触偏移调大能让物体提前产生接触力穿透减少但会出现看起来悬空的视觉偏差静止偏移控制接触力为零时的间隙。机器人抓取这类需要精确接触力的场景这组参数的影响比迭代次数大得多。持久接触流形开关打开后接触点会在连续帧之间保持连贯摩擦力的累积方向不会每帧乱跳。堆叠场景必开。接触点归约开关把多个接触点合并成一个等效点能显著减少抖动但会损失力矩信息做精细装配的时候要慎重。摩擦模型补片摩擦是默认项也是最稳的单向摩擦在特定场景下性能更好但摩擦力方向被限制在一个主方向上会出现推得动但转不动的现象。做轮式机器人或者传送带仿真时这个差异非常要命。参数调优的通用思路是先关掉所有稳定化辅助用最小场景复现问题定位到底是接触生成不准还是求解器收敛不够再针对性开相应的开关。一上来就把所有稳定化选项全打开问题是被压住了但你也永远不知道根因在哪换个场景又会冒出来。2.3 质量、惯量与 CCD三个被低估的稳定性来源很多人调不稳定的时候第一反应是加迭代次数但实际上最大的元凶往往在质量配置上。PhysX 提供了根据形状和密度自动算质量和惯量的辅助函数这是最省事也最推荐的做法。手动指定质量的时候惯量张量如果填错了物体在旋转上的行为会完全不符合直觉——比如一个长条物体被撞之后像陀螺一样旋转。更隐蔽的问题是质量比两个接触物体的质量比超过大约一百比一时求解器的收敛会明显变差。做机械臂抓小零件这种场景质量比轻松就能到几百这时候必须把零件的质量人为放大或者把整条链的质量重新分配让质量比落回合理区间。连续碰撞检测CCD是另一个高频误用点。它有两种模式投机接触模式在物体接近时提前生成约束开销小但精度有限扫掠模式在时间上求解接触时刻精度高但代价大。实践中不是所有物体都需要开 CCD正确做法是给高速且薄的物体开——子弹、薄板、快速运动的夹爪指尖而静态物体和低速物体一律不开。全场景开 CCD 会让性能掉得很难看而且很多情况下并不解决问题。3. 岛屿并行与 GPU 单核启动把并行开销藏在哪里3.1 岛屿管理器物理并行的天然切分物理仿真能不能并行关键在于约束图能不能被切成互不相干的连通分量。PhysX 里这个概念叫岛屿。两个物体通过接触或者约束连在一起它们就在同一个岛屿里两个隔离的物体堆就是两个岛屿。这个设计的意义在于同一个岛屿内部的约束必须串行求解因为互相影响但不同岛屿之间可以完全并行。所以 PhysX 的调度粒度是岛而不是物体它内部有一个任务管理器和 CPU dispatcher 配合把每个岛打包成一个可调度的任务丢给线程池。默认的 CPU dispatcher 会创建一个线程池线程数一般设成物理核心数减一因为主线程本身也在跑任务设满了反而因为调度争抢变慢。这里有个容易被忽略的事岛屿的切分质量决定了并行效率。如果你的场景是一大堆互相接触的物体挤在一起那就只有一个巨大的岛再多线程也没用只能串行跑。做大规模集群仿真的时候把物体在空间上分散开、避免出现全局连通的接触网比换更强的 CPU 更有效。3.2 确定性多线程和浮点归约埋的雷这是企业级选型里最容易被漏掉、出问题最致命的一条。PhysX 有一个增强确定性开关打开之后在同一台机器、同一个二进制、同样的插入顺序和同样的输入下仿真结果可以逐位复现。注意这几个限定条件一个都不能少换编译器版本不行换 CPU 指令集不行换线程数不行甚至物体创建顺序变了也不行。原因不复杂。不同指令集上 SIMD 指令的中间结果不同多线程下不同岛屿的求解顺序影响浮点累加顺序而浮点加法不满足结合律(ab)c和a(bc)在低位上就是不一样。对于做强化学习训练的团队这条直接决定实验能不能复现。业界的常见做法是训练时的输入状态用固定精度截断、比较时用容差而不是逐位相等同时把线程数固定下来哪怕机器有更多核把复现当成一个工程约束而不是引擎特性来对待。3.3 GPU 侧单核启动与 SOA 布局的设计取舍GPU 求解器这块是我读得最久的部分。核心设计有两点值得单独说。第一是单内核启动。GPU 仿真的一个流水线包含宽相、窄相、接触流形构建、约束准备、求解、积分等十几个阶段。如果每个阶段都单独启动一个 CUDA 内核启动开销加同步开销会吃掉大部分收益。PhysX 的做法是把这些子阶段做成可被块位掩码选择的功能块在一次内核启动里按掩码顺序执行把多次启动压成一次。源码里能看到一条流水线上下文把各阶段串起来具体哪些块落在同一次启动里是可以配置的这在调优时是个很有用的抓手——某些阶段之间存在数据依赖必须分两次启动而某些阶段合并起来能省不少时间。第二是结构体数组加锚点索引的数据布局。GPU 上最怕的是指针跳转和零散的内存访问。PhysX 把每个物体的仿真数据拆成几个大的连续数组然后在数组之间用锚点索引互相引用而不是用指针。这样重排、扩容、部分数据上传的时候都不需要修复指针缓存局部性也好。代价是代码可读性下降——你读 GPU 侧源码的时候会看到大量的索引计算和偏移宏一开始很痛苦。我的建议是先用 CPU 侧的对应实现对照着读两边数据结构其实是一一映射的。3.4 GPU 什么时候真的更快这是所有做选型的人最关心的问题但网上的数字大多是厂商给的理想值。我们在自己的场景里测下来的结论是场景规模CPU 多线程GPU结论数千刚体、接触稀疏更快更慢数据搬运和内核启动开销占主导一两万刚体、接触密集持平略快拐点区域结论随场景波动十万级刚体或大量关节明显更慢数倍优势GPU 的真正战场还有几个隐性成本必须算进去一个进程创建 CUDA 上下文本身有固定开销显存不够时引擎会退回 CPU 但通常不会报错多 GPU 一般要求同机部署跨节点要通过上层框架自己切分容器环境里如果没透传设备行为和在宿主机上跑完全不一样。做尽调的时候显存不足时的降级行为和能不能拿到 GPU 数据而不回拷这两条比峰值性能数字更值得写进报告。关于不回拷数据PhysX 5.3 之后提供的直接 GPU 访问接口是个关键能力它把 GPU 上的仿真缓冲区以原始指针的形式暴露出来上层可以直接读写不需要每个仿真步都把状态拷回 CPU 再处理。对于强化学习这种每步都要读状态、写动作的循环这个接口的意义是决定性的因为它消掉了一次全量的设备到主机传输。不过用它就要自己管同步和内存生命周期用错会读到半更新的数据这块我在第 5 节还会提。4. 关节、软体与粒子源码级看三类高级特性4.1 归约坐标关节为什么比刚体加约束稳做机器人仿真的人绕不开一个问题机械臂到底该用一串刚体加上广义关节约束拼起来还是用引擎原生的关节对象。原生关节归约坐标关节的思路是整个机构的状态用一组关节角广义坐标描述关节约束在内部被解掉外部看到的是一个整体。它不会出现关节缝隙因为关节约束根本没有机会被违反——它在数学构造上就是满足的。而且在求解时引擎可以利用关节树的稀疏结构用远低于通用刚体约束的代价算出关节力的分布。用刚体加重型六自由度约束拼出来的伪关节本质上是给两个自由刚体之间加约束约束是软约束会被外力违反表现为关节在受力时产生肉眼可见的缝隙和漂移而且每个关节都要占用约束求解器的预算。结论很清楚只要能拆成树形结构就用原生归约坐标关节。麻烦在于闭合运动链——四足机器人的某些结构、并联机构、有回环的传动这些源头上的树形假设就不成立。这是这类引擎的一个经典难点业界的常见处理是把闭环拆成两棵子树在末端用一个通用的六自由度约束连回去牺牲一部分稳定性换结构上的可行性。做并联机构的团队选型时一定要把这条列进风险项。4.2 驱动模式与关节参数机器人控制接口的本质归约坐标关节的驱动提供了几种模式选错了会让你的控制器白调。加速度模式和力模式的区别在于前者更像位置伺服引擎内部会把目标位置和当前状态的差换算成加速度适合做位置控制或者速度控制后者是直接施加力矩控制器的输出原封不动地作用到关节上。用位置伺服去做力控实验你会发现怎么调参数都不对因为你的力矩指令被内部的伺服环吃掉了。驱动里的刚度、阻尼和最大力三个参数本质上是引擎内部实现的一个隐式弹簧-阻尼系统。隐式的原因是显式弹簧在高刚度下会数值爆炸隐式的解法把这个限制放宽了。实用建议是刚度决定跟踪精度阻尼决定收敛速度最大力是安全阀。调的时候先把最大力放开调刚度和阻尼到稳态误差可接受最后再把最大力收回来做限幅。还有一个经常被忽略的参数是关节附加惯量。给关节加上一个小小的附加惯量能让低惯量、高减速比的关节典型的谐波减速器关节在数值上稳定很多。原理是这类关节在数学上接近奇异附加惯量把奇异点推远。代价是引入一点点动力学偏差但对仿真的稳定性来说非常划算。4.3 可变形体与粒子能力边界要说清楚PhysX 5 里可变形体分成两类体积型基于有限元的四面体网格和表面型基于三角网格的布料类。两者都需要离线烘焙网格材料参数用的是杨氏模量、泊松比这一类物理量。粒子和流体走的是基于位置的动力学路线通过粒子缓冲区提交数据相位标记决定某个粒子是流体还是自碰撞粒子还支持泡沫、水花这类扩散粒子效果。这里必须说清楚能力边界这些高级特性基本绑在 GPU 路径上。CPU 侧要么不支持要么支持得非常有限。这意味着如果你的部署环境里没有可靠的 GPU这些特性在选型阶段就该划掉而不是等到集成时才发现。另外可变形体与刚体的耦合、自碰撞的开关、粒子与刚体的双向作用这些组合场景的性能开销和稳定性差异很大务必在自己的目标场景里实测不要信官方示例的数据。5. Omniverse 这一层到底做了什么5.1 USD 是作者态PhysX 是求解态中间必须有一层翻译很多人以为 Omniverse 里的物理就是PhysX 换了个壳这个理解偏差会导致在排障时完全找错方向。实际的分工是USD 负责描述场景长什么样几何、层级、材质、关节定义PhysX 负责算它怎么动中间必须有omni.physx这层扩展负责翻译。USD 侧的物理描述由两套 schema 组成一套是通用的标准物理 schema负责刚体、碰撞、质量、关节这类通用概念另一套是 PhysX 的私有扩展 schema承载标准 schema 表达不了的东西——求解器迭代次数、接触偏移、关节驱动的刚度阻尼、可变形体和粒子系统的参数。所以你在 USD 文件里看到的很多物理属性是挂在私有扩展命名空间下的这一点在做跨工具资产交换时会造成兼容问题换到别的工具里这些私有属性直接被忽略默认值接手仿真行为就变了。5.2 Fabric 镜像与写回开关批量训练里最贵的一个按钮这是我在 Omniverse 侧花时间最多、也最有收获的一处设计。USD 是一个面向作者的数据结构层级深、属性访问要走路径解析每帧遍历一遍开销不小。Omniverse 的做法是在 USD 之外维护一份扁平的运行时场景Fabric物理引擎直接对着这份扁平数据工作USD 只在需要的时候被读写。关键在于写回这个动作的方向。仿真结果要不要反写回 USD在单机交互式场景里写回是必要的因为你希望其他扩展渲染、脚本能看到物体动了。但在批量训练场景里如果每个仿真步都把几千个物体的位姿写回 USD 的属性上会产生大量的属性通知和变更传播性能损失可以到一半以上。做批量训练时必须把写回关掉只通过张量接口直接读写仿真状态。这个开关是不是打开往往就是一个训练环境能用和不能用的分界线。5.3 张量接口为什么比逐个对象调 API 快几个数量级张量接口的设计是把一批同类对象抽象成一个视图——比如一个关节视图对应场景里所有的机械臂实例读关节位置返回的是一个形状为实例数 × 关节数的张量写关节目标同理。整个操作在 GPU 上批量完成不落 CPU。这和循环调用每个物体的 Python 接口的差别不是常数级的。逐个调用意味着每个物体都要穿过 Python 绑定、参数编组、状态同步几千个实例下光接口开销就能吃掉整个仿真预算。张量视图把这些开销摊薄成一次调用剩下的就是纯数据搬运。用它要注意几点视图是在场景加载后创建的实例数量和顺序必须和你的训练环境一一对应视图的读写有同步语义读关节状态会触发一次同步所以不要在一帧里读几十次把需要的状态一次性读出来多 GPU 或者多实例训练时每个子环境对应独立的物理场景和视图视图对象不能跨场景复用。5.4 版本与许可尽调报告里最该写清楚的两条第一条是版本绑定。Omniverse 上层的物理行为由 Kit 版本和其内置的 PhysX 版本共同决定你不能把底层的开源 PhysX 换成自己编的版本至少不是常规手段能做到的。这意味着如果你的项目需要给 PhysX 打补丁比如修一个影响你业务的接触生成 bug或者加一个自定义的关节模型在 Omniverse 栈里这条路基本走不通只能走自己基于开源 PhysX 搭一套的路线。这个决策的分量很重选 Omniverse 就是选省事但不可改选裸 PhysX 就是选完全可控但全都得自己搭。第二条是许可分层。开源 PhysX 本身用的是宽松的三条款许可商用没有障碍代价是分发时要保留版权声明和许可文本而且它以按现状提供的方式给出没有性能或适用性担保。但它依赖的 CUDA 运行时是另一套许可分发形态受约束仓库里通过包管理器拉下来的预编译依赖也各有各的许可商用前需要把这些依赖逐个过一遍。上层的 Omniverse 平台则是独立的产品授权跟底层的开源许可不是一回事这一层必须单独找商务确认。把这三层搞混是尽调报告里最常见也最容易被法务打回来的问题。6. 一份可执行的尽调清单以及我踩过的那些坑6.1 拿到源码后应该先做的五件事顺序很重要我按实际踩坑的顺序列先编 CPU-only 版本跑通官方 snippet。这一步的目的是确认工具链和依赖没问题而不是验证功能。很多人跳过这步直接编 GPU 版本遇到报错完全分不清是 CUDA 环境问题还是工程配置问题。打开增强确定性跑一遍自带单元测试。仓库里有基于通用测试框架的单元测试集能跑通说明你的构建是可信的。注意 debug 版本和 release 版本的性能差异可以到数倍别拿 debug 版的数字做结论。把 allocator、error callback、profiler 三个回调接上你自己的实现。这一步花不了多少时间但它决定了后续所有性能分析的可信度。三个都接上之后你能拿到每个仿真阶段的分段耗时而不是只有一个总时长。用可视化调试工具连一次。PhysX 自带一套可视化调试协议能实时看到接触点、约束、岛屿划分、求解器收敛情况。第一次看到自己场景里浮现出几千个接触点的时候很多玄学抖动会瞬间变得有解释。最后才是拿自己的目标场景做基准测试并且要按前面的表格分规模档位测别只测一个规模就下结论。6.2 常见问题与对应根因现象常见根因处理方向物体之间持续抖动质量比过大、接触流形不稳定重分配质量、开持久接触流形、考虑换 TGS慢速滑动时物体粘住静摩擦系数过大或摩擦模型选择不当调材料摩擦系数、换摩擦模型开启 GPU 后没变快场景规模没到拐点或显存不足静默退回 CPU查 GPU 内存配置、检查是否真的在用 GPU 库训练结果无法复现多线程、浮点归约顺序、驱动版本差异固定线程数、固定版本、比较用容差关节在受力时出现缝隙用了刚体加通用约束拼的伪关节换成原生归约坐标关节把渲染网格直接当碰撞网格面数过高的三角网参与接触计算单独做简化碰撞体凸包或胶囊精细场景接触不稳定最小特征尺寸过小容差失配放大场景尺度计算后换算回结果6.3 要不要 fork一个必须现在回答的问题最后一个问题也是最贵的那个问题源码开放意味着你可以改但改不改得起是另一回事。给一个判断框架。如果你的需求集中在参数调优、场景组织、上层算法那不需要 fork跟着上游用就好。如果你需要修的 bug 影响核心业务、需要的特性上游路线图上短期没有、或者你需要一个严格受控的确定性环境比如做仿真到现实的迁移要求每个版本的行为逐位固定那么 fork 是合理的。但 fork 的代价要提前算清楚每次上游更新你都要做一次合并物理引擎这种核心代码的冲突解决成本极高你内部的改动需要自己维护测试用例否则几个月后没人说得清哪个改动是为了什么对外交付时还要处理许可和版本声明的合规问题。我的经验是fork 之前先试着用回调机制和上层封装解决问题——PhysX 提供的过滤回调、接触修改回调、错误回调、分配器回调能覆盖的需求比大多数人想象的多很多看起来非改源码不可的需求其实在外围就能解掉。真到了必须动源码的地步也只改最小的面改完立刻在上游提能被合并进去是最省的长期方案。