
PhysX SDK源码深度解析一次碰撞背后物理引擎到底在做什么【免费下载链接】PhysXNVIDIA PhysX SDK项目地址: https://gitcode.com/gh_mirrors/ph/PhysX你有没有想过当游戏里一个箱子从高处落下、砸到地面弹了一下然后稳稳停住这短短一秒里CPU到底执行了多少行代码如果你只调用过simulate()和fetchResults()这两个接口那你看到的只是物理引擎的冰山一角。NVIDIA PhysX SDK 之所以能成为游戏和仿真领域的事实标准靠的不是魔法而是二十余年沉淀下来的一套精心设计的内部架构。本文直接从 PhysX 4.1 的源码出发拆开physx/source/目录带你回答一个核心问题一次物理模拟内部究竟是怎么一步步跑完的。一图看懂全局PhysX 源码目录就是一张架构图在动手读源码之前先建立全局心智。PhysX 的目录结构本身就是它的分层架构说明书目录职责physx/include/对外公开的 C APIPxScene、PxRigidDynamic、PxPhysics 等physx/source/foundation/内存分配、数学库SIMD、错误处理等基础设施physx/source/geomutils/几何查询与窄相位碰撞检测算法physx/source/lowlevelaabb/宽相位 Broad PhaseSAP / MBP / ABP 三种算法physx/source/lowleveldynamics/刚体动力学、Featherstone 铰接体算法physx/source/simulationcontroller/把上面所有模块编排成每帧模拟的中枢physx/source/physx/src/高层实现NpPhysics、NpScene把 API 请求翻译成底层调用physx/snippets/官方示例最直观的 API 使用入门PhysX核心架构依赖图从 PxPhysics 工厂对象发散出场景、Actor、Shape 等全部物理对象这张依赖图很能说明问题PxPhysics是整个 SDK 的总工厂它创建场景、刚体、材质而PxRigidActor/PxRigidDynamic/PxShape都从它下面生长出来。高层 API 只负责描述世界长什么样真正的物理计算全在source/里那些Bp、Gu、Dy、Sc前缀的类中完成。记住一句话API 是面子source 是里子。核心机制一为什么宽相位要先筛一遍候选对——SAP 算法的空间加速它解决什么问题一个 1000 个物体的场景如果两两做精确碰撞检测每一帧要测C(1000,2) ≈ 50万对。这显然不可行。物理引擎的解法是先做一次粗略筛选用每个物体轴对齐包围盒AABB快速排除明显不相交的对只把可能相交的候选对交给昂贵的精确测试。这个筛选阶段就是宽相位Broad Phase。为什么选 SAPSweep and Prune而不是暴力遍历因为 SAP 的核心思想——把三维问题降维成一维排序——让它在物体分布较均匀的场景里能达到近似线性的复杂度。关键源码位置与代码宽相位的三种实现就躺在physx/source/lowlevelaabb/src/里BpBroadPhaseSap.cpp— 经典 SAPBpBroadPhaseMBP.cpp— 多盒裁剪Multi-Box Pruning适合大量散乱分布物体BpBroadPhaseABP.cpp— 自动盒裁剪PhysX 4.1 在 CPU 上的默认选择看 SAP 的构造函数它一上来就把每个轴上的端点预分配好// BpBroadPhaseSap.cpp for(PxU32 i0;i3;i) mBatchUpdateTasks[i].setContextId(contextID); // Boxes mBoxesSize0; mBoxesSizePrev0; mBoxesCapacity (((maxNbStaticShapes maxNbDynamicShapes) 31) ~31); mBoxEndPts[0] reinterpret_castSapBox1D*(PX_ALLOC(ALIGN_SIZE_16((sizeof(SapBox1D)*mBoxesCapacity)), SapBox1D)); // ... 三个轴各有一份端点数组 mEndPointsCapacity mBoxesCapacity*2 NUM_SENTINELS;这段代码翻译成人话每个物体的 AABB 在三个轴上各有最小值端和最大值端所以每轴需要物体数×2个端点代码里mBoxesCapacity*2就是这个道理mBoxEndPts[0/1/2]是三个轴各自的端点数组每次分配都带 16 字节对齐ALIGN_SIZE_16这是为了后续 SIMD 指令能直接处理这些内存NUM_SENTINELS哨兵是排序里的无穷大/无穷小占位符用来简化边界判断所有内存都在构造时一次性申请Capacity 预先算好运行时只更新内容不频繁分配——这是高性能物理引擎的通用铁律。那降维到底怎么工作每帧调用update()时SAP 把三个轴上的端点重新排序然后只需要扫描相邻端点之间是否有重叠区间若两个盒子的区间在某个轴上不重叠这对物体直接跳过只有三个轴都重叠的才会被输出为候选对交给窄相位做精确碰撞检测。排序本身是O(n log n)扫描是近似O(n k)k 是重叠对数整体比O(n²)暴力法快出几个数量级。这个机制的精髓一句话用一维排序代替三维配对用粗筛换取精测的昂贵成本最小化。PhysX几何系统依赖图宽相位筛选出的候选对最终要交给这里定义的各类精确几何求交算法核心机制二接触点从哪来——窄相位与 GuContactPoint它解决什么问题宽相位告诉你这两个物体可能撞上了但还不够物体到底在哪个点接触法线朝哪穿透多深这些信息决定了物体接下来的反弹方向和力度。窄相位Narrow Phase就是干这个的——针对球、盒、胶囊、三角网格等每一种形状组合做精确的几何求交。关键源码位置与代码窄相位的接触点数据定义在physx/source/geomutils/include/GuContactPoint.hstruct GuContactPoint { PxVec3 normal; // 接触法线 PxVec3 point; // 接触点位置 PxReal separation; // 分离距离负值表示穿透深度 };class GuContactBuffer { public: GuContactPoint* contactPoints; // 接触点数组 PxU32 nbContacts; // 当前接触点数量 // ... PX_FORCE_INLINE bool contact(const PxVec3 worldPoint, const PxVec3 worldNormal, PxReal separation) { if(nbContacts maxContacts) return false; // 缓冲区已满停止收集 GuContactPoint p contactPoints[nbContacts]; p.normal worldNormal; p.point worldPoint; p.separation separation; return true; } };这段代码值得逐行品contact()是窄相位算法填充接触点的统一入口球球、球盒、盒盒等所有求交函数最后都往这里写数据nbContacts maxContacts时的提前返回不是偷懒——接触点数量有上限通常 4 个因为求解器只需要少量点就能稳定约束多点反而浪费注意separation的语义负值代表穿透深度。求解器看到separation 0就知道要推开多少才能消除重叠contact()标记了PX_FORCE_INLINE因为它会在每帧被调用成千上万次函数调用开销都要省掉。窄相位的具体求交算法如 GJK、SAT、Sweep在physx/source/geomutils/src/下按形状对划分实现。这里的关键设计决策是算法按形状组合特化而不是走统一的泛型路径——球对球可以用纯数学公式秒解根本不需要跑昂贵的 GJK 迭代。特化带来的代码量增加换来了 10 倍以上的性能差距。这个机制的精髓一句话宽相位负责猜窄相位负责算而接触点缓冲区的固定上限设计让算的成本有硬边界。PhysX动态刚体依赖图接触点求解结果最终要作用在 PxRigidDynamic 这样的刚体上改变它的速度与角速度核心机制三引擎怎么扛住每帧几万次计算——任务系统与内存管理它解决什么问题物理模拟天然可并行宽相位、窄相位、求解器都可以切成很多小任务并行跑。但多线程最怕的是锁和乱序。PhysX 的答案是自研任务系统 无锁的数据流依赖 预留内存池。关键源码位置与代码在physx/source/physx/src/NpScene.cpp里simulate()的真正入口是simulateOrCollide()// NpScene.cpp if (controlSimulation) { mTaskManager-resetDependencies(); // 1. 重置依赖关系 mTaskManager-startSimulation(); // 2. 启动模拟 } if (simStage Sc::SimulationStage::eCOLLIDE) { mCollisionCompletion.setContinuation(*mTaskManager, completionTask); mSceneCollide.setContinuation(mCollisionCompletion); // ... mCollisionCompletion.removeReference(); mSceneCollide.removeReference(); } else { mSceneCompletion.setContinuation(*mTaskManager, completionTask); mSceneExecution.setContinuation(*mTaskManager, mSceneCompletion); // ... mSceneCompletion.removeReference(); mSceneExecution.removeReference(); }白话解读这段任务编排resetDependencies()startSimulation()每帧先清空上一帧的任务依赖图再重新建立controlSimulation为 true 表示本场景独占这个 TaskManagersetContinuation()是任务系统的核心 API——它定义谁必须在谁之后跑例如mSceneCollide完成后才能跑mCollisionCompletionremoveReference()配合引用计数触发执行任务被发布进调度器后引用计数归零时自动排队执行整个simulate()调用是非阻塞的——它只负责搭好任务图然后立刻返回真正的计算由工作线程异步完成。这就是为什么你必须稍后调用fetchResults()去收割结果。任务节点本身定义在simulationcontroller/src/ScScene.cpp里mBroadPhase (contextID, this, ScScene.broadPhase), mUpdateBodiesAndShapes(contextID, this, ScScene.updateBodiesAndShapes), mUpdateDynamics (contextID, this, ScScene.updateDynamics),可以看到宽相位、刚体更新、动力学求解都被封装成独立任务节点它们的依赖关系构成了每帧模拟的流水线。内存管理方面physx/source/foundation/include/PsAllocator.h提供了统一分配宏#if PX_CHECKED #define PX_ALLOC(n, name) physx::shdfnd::NamedAllocator(name).allocate(n, __FILE__, __LINE__) #else #define PX_ALLOC(n, name) physx::shdfnd::NonTrackingAllocator().allocate(n, __FILE__, __LINE__) #endif #define PX_FREE(x) physx::shdfnd::NonTrackingAllocator().deallocate(x)这里最妙的 trade-off 是Checked 版本调试/发行带检查用NamedAllocator能记录每次分配的调用点内存泄漏一抓一个准Release 版本PX_CHECKED关闭退化成NonTrackingAllocator省掉所有簿记开销——同样的代码两种行为而且这些分配器最终都回调用户提供的PxAllocatorCallback这就是为什么你能在PxCreateFoundation()时注入自定义内存池比如游戏引擎自己的分配器。这个机制的精髓一句话物理计算性能靠的是任务图并行 预分配内存而不是靠锁和动态 new。一次完整流程走读从创建场景到箱子落地现在我们把上面的机制串成一条完整的调用链。以官方示例physx/snippets/snippethelloworld/SnippetHelloWorld.cpp为蓝本跟着一个箱子的完整生命走一遍。第 1 步搭地基——创建 Foundation 和 Physics// SnippetHelloWorld.cpp gFoundation PxCreateFoundation(PX_PHYSICS_VERSION, gAllocator, gErrorCallback); gPhysics PxCreatePhysics(PX_PHYSICS_VERSION, *gFoundation, PxTolerancesScale(), true, gPvd);PxCreateFoundation把用户自定义的分配器和错误回调注册进系统PxCreatePhysics则创建核心工厂对象。注意第五个参数gPvd——PVDPhysX Visual Debugger从这里开始挂在引擎上实时观察模拟。第 2 步建世界——创建场景、材质、地面PxSceneDesc sceneDesc(gPhysics-getTolerancesScale()); sceneDesc.gravity PxVec3(0.0f, -9.81f, 0.0f); gDispatcher PxDefaultCpuDispatcherCreate(2); // 2 个工作线程 sceneDesc.cpuDispatcher gDispatcher; sceneDesc.filterShader PxDefaultSimulationFilterShader; gScene gPhysics-createScene(sceneDesc);PxSceneDesc是场景的配方重力、线程调度器、过滤着色器。createScene内部会走到NpScene把所有底层模块宽相位、窄相位、求解器实例化并接好线。创建后实际跑的是Sc::Scene它持有前面看到的mBroadPhase、mUpdateDynamics等任务节点。第 3 步造物体——箱子入世PxRigidDynamic* body gPhysics-createRigidDynamic(t.transform(localTm)); body-attachShape(*shape); PxRigidBodyExt::updateMassAndInertia(*body, 10.0f); gScene-addActor(*body);createRigidDynamic在底层创建NpRigidDynamicattachShape把碰撞形状绑定到刚体上updateMassAndInertia根据形状自动计算质量和惯性张量这是刚体动力学正确性的关键。addActor把物体注册进场景同时通知宽相位为它创建 AABB 端点。第 4 步模拟循环——simulate 的非阻塞承诺// SnippetHelloWorld.cpp void stepPhysics(bool /*interactive*/) { gScene-simulate(1.0f/60.0f); // 提交 1/60 秒的模拟请求 gScene-fetchResults(true); // 阻塞等待结果 }这一步对应NpScene::simulate()→simulateOrCollide()前面已经看过它校验状态elapsedTime 0、16 字节对齐、上一帧必须已 fetchResults、同步材质、搭好任务依赖图然后立即返回。fetchResults(true)则阻塞到所有任务完成把结果写回刚体的位姿和速度。第 5 步内部流水线——模拟阶段逐层推进在Sc::Scene内部这一帧实际经历Broad PhaseBpBroadPhaseSap::update()排序 AABB 端点产出候选重叠对Narrow Phasegeomutils/src各求交函数对候选对做精确求交通过GuContactBuffer::contact()填充接触点Island 生成把通过接触/关节连通的刚体聚成岛独立的岛可以并行求解、也可以整体休眠Solver求解器对每个接触点/关节迭代求解约束PGS 迭代算出修正冲量Integrate积分把冲量累加成速度、再把速度积分成新位姿。第 6 步沉睡与唤醒——省电模式ScScene.cpp里有一段关键逻辑// ScScene.cpp // If we got in this code, then this is an active object this frame. The solver computed the new wakeCounter... bodyCore.wakeCounter bodyCore.solverWakeCounter;箱子落地静止后求解器会逐渐降低它的wakeCounter直到归零进入休眠——休眠物体不参与宽相位和求解但它们在场景里依然存在。这正是 PhysX 能支撑上千物体场景的秘密之一大部分物体大部分时间都在睡觉。性能与取舍分析每个快背后都牺牲了什么设计决策得到了什么牺牲了什么固定容量预分配内存运行时零分配、缓存友好峰值内存偏高容量预估需谨慎SAP/MBP/ABP 特化宽相位各场景适配最优算法算法选择需要开发者理解场景特征接触点数量硬上限求解成本有边界极端堆叠时可能丢失细节接触非阻塞 simulate 任务图与渲染帧并行充分利用多核API 使用复杂度上升必须配对 fetchResults形状对特化求交球球等简单情况极快代码量大维护成本高休眠系统静止物体零开销休眠/唤醒瞬间可能出现微小跳变其中最值得玩味的是非阻塞 simulate。它把物理计算和等待结果彻底解耦渲染线程调用simulate()后立刻去准备下一帧的画面物理工作线程同时开跑。代价是状态机复杂度剧增——NpScene.cpp里处处是SimulationStage校验eCOMPLETE、eCOLLIDE、eADVANCE、eFETCHCOLLIDE用错顺序就会收到那条经典的报错Simulation is still processing last simulate call, you should call fetchResults()!源码视角下的五个避坑要点以下每条都能在源码里找到证据不是经验之谈忘记 fetchResults 会连锁报错。NpScene::simulate()第 1861 行直接检查getSimulationStage() ! eCOMPLETE上一次模拟没结果就再次 simulate直接eINVALID_OPERATION报错返回。证据physx/source/physx/src/NpScene.cpp。scratch block 必须 16 字节对齐且大小为 16K 整数倍。第 1870-1872 行用PX_CHECK_AND_RETURN硬校验不满足连模拟都不启动。证据同上文件。不要每帧创建/释放刚体。BpBroadPhaseSap.cpp构造函数里所有内存按最大容量一次性PX_ALLOC频繁增删物体会触发容量扩张和重新分配性能雪崩。证据physx/source/lowlevelaabb/src/BpBroadPhaseSap.cpp。FilterShader 别轻易返回都要接触。ScNPhaseCore.cpp第 169 行会输出警告没有接触报告、没有eSOLVE_CONTACT的对会被建议直接 kill——无用的重叠对每帧都在消耗宽相位和窄相位的算力。调用顺序混乱是最大的坑。NpScene.cpp中collide() / advance() / fetchResults()有严格顺序要求每步都做SimulationStage断言。想拆分离散/连续碰撞的开发者务必先读懂eCOLLIDE → eFETCHCOLLIDE → eADVANCE → eCOMPLETE这个状态机。进阶学习路线从 API 到源码的三级跳第一级跑通示例读懂 API 层。先编译运行physx/snippets/snippethelloworld/对着SnippetHelloWorld.cpp理解PxFoundation → PxPhysics → PxScene → PxRigidDynamic的创建链。接着读physx/include/PxPhysics.h和PxScene.h把每个纯虚函数的注释当需求文档读。第二级读实现追调用链。以gScene-simulate()为入口顺着NpScene::simulate()→Sc::Scene的任务节点 →BpBroadPhaseSap::update()→ 窄相位求交 → Solver把一条调用链走通。用调试器在这几个函数上打断点看任务依赖图怎么一步步被触发。第三级改源码验证理解。三个适合动手的点给GuContactBuffer加一个自定义接触点收集逻辑在ScScene.cpp的updateDynamics任务里插入自定义调试输出尝试把宽相位从 ABP 切成 SAP用generate_projects.sh重新生成工程对比性能。改动要小、能跑、可观测这是检验理解的最佳方式。总结快不是魔法是设计PhysX SDK 的每个快都能在源码里找到对应的设计决策宽相位用排序换掉暴力配对、窄相位用特化换掉泛型、任务系统用依赖图换掉锁、内存池用预分配换掉运行时 malloc、休眠系统用状态换掉算力。它的演进方向也清晰可见GPU 加速求解PxSceneDesc里的eGPU宽相位、软件/硬件混合管线、更精细的接触模型——这些在 4.1 的源码里都已经埋下伏笔。下一步你可以做的第一件事打开physx/snippets/snippethelloworld/SnippetHelloWorld.cpp在createStack()里把箱子的数量从 10 改成 1000跑起来看性能曲线然后回到源码里找答案——为什么是宽相位、休眠系统、还是求解器成了瓶颈找到瓶颈的那一刻你就真正读懂了物理引擎。关键词: PhysX SDK源码分析, NVIDIA物理引擎内部实现, 宽相位SAP算法, 窄相位接触点, 物理引擎任务系统, 刚体动力学, 模拟循环源码走读, 物理引擎性能优化【免费下载链接】PhysXNVIDIA PhysX SDK项目地址: https://gitcode.com/gh_mirrors/ph/PhysX创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考