Unity引擎源码-物理系统详解-其三
今天我们来深入了解一下PhysX引擎底层的具体实现,当然话是这么说,但显然我的能力有限,不太可能在这么短的时间全部融会贯通,我就说一下我自己的视角能看到的东西吧。
首先整个PhysX引擎干的事情非常简单:就是物理的模拟,那么我们想象一下自己生活中的真实情况:我们在生活中把物体分为了固液气三种形态,于是PhysX也要对物体有一个基本的分类:在计算机中,这样分类的根本原因是为了去写相关的数据结构,比如液体和固体,你写相关基类的时候显然需要的内容不一样。然后在物理引擎中我们把所有物体会发生的内容浓缩成了这样一个情形:我们首先要专门建立一个物理场景,这样做的意义很多:首先是不同物理场景的一些环境参数可能不同,我们都生活在地球上,大家的重力加速度大差不差,但是游戏可没说你在地球上,游戏是想象力的延申,建立不同的物理情景可以让我们针对不同情况应用不同参数;其次其实是最根本的原因是:我们的PhysX引擎是基于面向对象来写的代码,我们只会建立一个个类,但是具体如何应用这些类还是需要我们自己去物理场景里写逻辑。我们建立好物理场景之后就需要去添加一系列物体,在这个过程中其实会涉及到非常复杂的内容,但是PhysX引擎将这个过程简略成了两个最重要的过程:碰撞和约束。
说到碰撞,我们会浮现出两个小车相向而行之后发生激烈碰撞的画面,但是其实不止是这样的碰撞才叫做碰撞。当我们有一个小球停在平面上,小球和平面就组成了一个碰撞对:注意这里的碰撞对,我们会在后续反复提到,这是个非常重要的概念。从这个角度来看,我们的碰撞其实和日常生活中的接触含义更接近,我们的碰撞分为了碰撞的检测和响应,检测干的事就是返回一个布尔变量的事,而响应则是根据碰撞的条件来执行逻辑。但是问题是这样来看,一个场景中有太多碰撞对了:我们的流体(流动的液体或者气体)一般用粒子系统实现,在高质量的渲染中,一块布料就要上万个粒子来模拟,理论上来说这一万个粒子中两两之间都会构成碰撞对(其实布料的模拟过程中粒子与粒子之间应该是有一个关节系统的,但是意思差不多),那如果每个碰撞对都要去求碰撞响应,这时间复杂度,真不是一般的大,所以这个时候我们还会引入一个内容叫做岛屿系统。说实话我合理怀疑这个东西叫做岛屿系统是因为发明者也写过岛屿问题(kidding),因为其实核心思想和你在力扣上写的岛屿数量问题差不多:我们通过dfs或者bfs来从一对碰撞对处开始检查所有与当前的碰撞对有碰撞的物体,无限查询直到确定没有其他物体之后停止,这些有关联的碰撞对就是一个岛屿,我们去计算这个岛屿内部的碰撞对即可,这样可大大减小时间复杂度。
那碰撞说完了,什么是约束呢?其实也很简单,约束就像你写代码的时候写了一大堆发现有些特定场景不符合之后去代码外部加的一些特判条件,约束就是规定你无论物理世界内部如何变化也一定不能发生的事,比如你的刚体无论如何运动也不会散架。当然实际的约束有哪些要根据具体情况来讨论,在代码中,约束的形式也无非是if语句罢了,写约束当然非常简单,但真正的难题往往是去求解约束:如何理解求解约束这个过程呢?我们要知道求解约束这个过程是发生在物理模拟之后的,他就像一个修正过程:我们先计算出物体模拟的结果,然后求解约束之后我们把一个修正的内容给到物理模拟的结果。所以求解约束干的事就是已知一个结果和一个条件之后计算出一个修正量给到结果,在后续的讨论中,我们会更加详细地介绍这个约束求解的过程。
今天我们讲解的过程中将按照PhysX内部代码功能的层级分类来介绍,总的来说可以分为五个层,分别是:
- 物理场景与架构基础
- 空间管理系统
- 约束求解系统
- 物体类型系统
- 扩展功能与优化
物理场景与架构基础
千里之行,始于足下。庞大的引擎起步也得从零星的一些基本结构做起,我们首先要去实现这样一个物理世界并完善对物理世界的管理,这样在后续完成各种各样的物体类之后才能放进场景内供我们使用。说到物理场景的构建,或者说一般说到这种场景搭建的设计,我们要先从上层视角来看待整个架构的搭建,比如一个非常经典的设计模式:工厂模式。
PxPhysics
工厂模式的做法类似于我们先去定义好一个工厂具体需要哪些内容,然后后续的具体实现中我们针对不同的需求去加内容:我定义工厂内一定得有流水线和货物,但是不同的厂里的货物不一样。在PhysX中也是这样,PxPhysics(文件位置: physx/include/PxPhysics.h):这就是我们的核心工厂类,负责管理所有的物理对象,注意是所有物理对象。
// 创建物理场景
virtual PxScene* createScene(const PxSceneDesc& sceneDesc) = 0;// 创建各种物理对象
virtual PxRigidStatic* createRigidStatic(const PxTransform& pose) = 0;
virtual PxRigidDynamic* createRigidDynamic(const PxTransform& pose) = 0;// 创建几何体和材质
virtual PxShape* createShape(const PxGeometry& geometry, const PxMaterial& material, ...) = 0;
virtual PxMaterial* createMaterial(PxReal staticFriction, PxReal dynamicFriction, PxReal restitution) = 0;
PxPhysics为我们隐藏了底层的各种物理对象的实现过程,而只是开放一个接口供我们使用。然后还有值得一提的是PxPhysics中的TolerancesScale类:这个类会规定我们对于一些微小误差的容忍程度:实际的物理引擎中对于很微小的误差计算是无法避免的,或者说如果对于一些及其微小的计算误差过于敏感,很容易出现物体始终不进入睡眠状态的情况(后续会提到,所谓的睡眠状态其实就是物体的一些参数小于睡眠阈值之后就会强制进入睡眠状态,从而不用再费心去计算其物理模拟结构直到再次被足够大的力唤醒)。为什么这个类不在场景描述符(PxSceneDesc)里是因为我们要求一整个引擎内部的容忍误差的程度是相等的才行而不能不同场景不同。
PxScene
然后就是我们具体的物理场景类了:PxScene(文件位置:physx/include/PxScene.h),这个文件就会去定义一些物理场景中必需的内容,从下列给出的代码也可以看出,有基本的调用物理模拟的函数也有设置处理物理对象的函数。
// 物理模拟的核心循环
virtual bool simulate(PxReal elapsedTime, ...) = 0;
virtual bool fetchResults(bool block = false, ...) = 0;// 对象管理
virtual bool addActor(PxActor& actor, const PxBVH* bvh = NULL) = 0;
virtual void removeActor(PxActor& actor, bool wakeOnLostTouch = true) = 0;// 环境参数设置
virtual void setGravity(const PxVec3& vec) = 0;
virtual PxVec3 getGravity() const = 0;
PxSceneDesc
这个是和物理场景搭配使用的,一般叫做物理场景描述:PxSceneDesc(文件位置:physx/include/PxSceneDesc.h),他的作用就是去具体的设置场景中的各个参数,从下列的代码中也可以看出,比如我们要定义一些场景中的回调,定义求解器的类型和宽相检测的类型。
class PxSceneDesc : public PxSceneQueryDesc {
public:PxVec3 gravity; // 重力向量PxSimulationEventCallback* simulationEventCallback; // 事件回调PxContactModifyCallback* contactModifyCallback; // 接触修改回调PxCCDContactModifyCallback* ccdContactModifyCallback; // CCD接触回调PxSceneFlags flags; // 场景标志PxSolverType::Enum solverType; // 求解器类型 (PGS/TGS)PxBroadPhaseType::Enum broadPhaseType; // 宽相检测类型PxSceneLimits limits; // 场景限制PxGpuDynamicsMemoryConfig gpuDynamicsConfig; // GPU配置
};
这里提到的场景标志可以多提一嘴,具体的代码实现方式是这样的:
struct PxSceneFlag {enum Enum {eENABLE_CCD = (1<<1), // 启用连续碰撞检测eENABLE_GPU_DYNAMICS = (1<<13), // 启用GPU动力学eENABLE_ENHANCED_DETERMINISM = (1<<14), // 增强确定性eENABLE_DIRECT_GPU_API = (1<<17), // 直接GPU API// ... 更多标志};
};
可以看到我们是用一个位左移来判断具体的场景参数,这是因为我们一般会先创建一个枚举来代表参数,然后可以让每个标志占用二进制表示中的一个位,这样一个整数就可以存储多个开/关状态。
Foundation
我们搭建好物理场景并设置好物理场景的参数之后呢,就需要一些最基础的内容帮助我们去保证物理环境正常运作,提供基本服务了。负责这部分内容的就是Foundation类(文件位置:physx/source/foundation),大体的内容如下所示:
- 内存管理: 自定义分配器,内存池管理
- 数学库: 向量、矩阵、四元数运算
- 平台抽象: 跨平台的原子操作、线程、互斥锁
- 错误处理: 断言、错误回调机制
// Foundation核心接口
class PxFoundation {
public:virtual PxAllocatorCallback& getAllocatorCallback() = 0;virtual PxErrorCallback& getErrorCallback() = 0;virtual void release() = 0;
};
Task
这个类(文件位置: physx/source/task/)没什么好说的,就是所有程序中往往都会设计的多线程任务调度系统,主要干的就是多线程那一套。
- 任务依赖: 任务间的依赖关系管理
- 线程池: 工作线程的管理和调度
- 任务队列: 任务的排队和分发
大体上这就是我们的物理基础系统的内容了,PhysX的整体架构设计如下:
应用层 (用户代码)
↓
API层 (PxPhysics, PxScene)
↓
模拟控制层 (SimulationController)
↓
底层系统 (LowLevel, Foundation)
这个可不是我乱写的,因为PhysX源码的文件夹里的结构就是差不多这样的:
而我们当前的物理场景与架构基础层级就是底层系统和模拟控制层的结合体,我们后续讲解的内容都是建立在这一层级的实现基础之上。
空间管理系统
空间管理系统是PhysX引擎的核心组成部分,负责处理物体在三维空间中的位置关系、交互检测和空间查询。大体上来说可以把这个部分分为三个部分:碰撞检测、场景的查询以及岛屿系统。
碰撞检测
碰撞检测对于整个物理系统的重要性不言而喻,一切我们在显示器上可见的炫酷的视觉物理效果的基础都是基于这个,在PhysX中,碰撞检测总的来说可以分为三部分:宽相的,窄相的以及CCD。
宽相碰撞检测
文件路径:
- physx/include/PxBroadPhase.h
- physx/source/physx/src/NpBroadPhase.h
- physx/source/lowlevel/common/src/pipeline/PxcBroadPhase.cpp
宽相检测就是负责从整个场景出发,通过一些算法检测场景中哪些物体可能会发生碰撞。话很简单,但是涉及的情况就非常多了,因此也引出了大量的算法,我们今天就只介绍最常见的几个:
SAP (Sweep-And-Prune)算法
SAP算法通过对物体的AABB包围盒在各坐标轴上进行排序,然后检查重叠区间来快速排除不可能碰撞的物体对。SAP特别适合物体分布均匀且移动较少的场景,其时间复杂度接近O(n log n)。
// physx/source/physx/src/NpBroadPhase.cpp (简化版)
void SapUpdateWorkTask::runInternal()
{mBP->update(mIndex, mChangedAABBMgr, mOutputChangedAABBs, mCreatedAABBMgr, mOutputCreatedAABBs, mRemovedAABBMgr, mOutputRemovedAABBs);
}// physx/source/geomutils/src/sweep/GuSweepSAPPruner.cpp
void SweepSAPPruner::updateObjects(const PrunerHandle* handles, const PxU32* indices, const PxBounds3* newBounds, PxU32 count)
{// 更新AABBfor(PxU32 i=0; i<count; i++){const PrunerHandle h = handles[i];const PxU32 index = indices ? indices[i] : i;const PxBounds3& newBound = newBounds[index];// 更新各轴上的边界mSAP.updateObject(h, newBound);}// 重新排序和更新重叠mSAP.sortAxis(0); // X轴mSAP.sortAxis(1); // Y轴mSAP.sortAxis(2); // Z轴mSAP.findOverlaps();
}
MBP (Multi-Box Pruning)算法
MBP使用空间哈希将三维空间划分为多个网格,只有位于同一网格或相邻网格的物体才可能发生碰撞。MBP在处理大规模动态场景时比SAP更高效,尤其是当物体数量众多且分布不均匀时。如果有人无法想象空间哈希的话,其大体流程就是将三维空间划分为大小相等的立方体网格,为每个网格单元分配一个唯一的哈希值并根据物体的位置将其映射到对应的网格单元,最后只检查同一网格或相邻网格中的物体是否可能碰撞。
// physx/source/geomutils/src/mesh/GuMBP.cpp
void MBP::prepareOverlaps()
{const PxU32 nbUpdatedBoxes = mUpdatedObjects.size();// 对更新的物体重新计算空间哈希码for(PxU32 i=0; i<nbUpdatedBoxes; i++){const PxU32 objectIndex = mUpdatedObjects[i];const MBPEntry& currentEntry = mMBP_Objects[objectIndex];const PxBounds3& currentBounds = mMBP_Entries[objectIndex];// 计算物体占据的网格单元mBP.updateObject(currentEntry.mHandle, currentBounds);}// 执行宽相检测,找出重叠的对象mBP.findOverlaps(mAddedPairs, mRemovedPairs);
}
BVH (Bounding Volume Hierarchy) 算法
BVH是一种空间划分数据结构,通过递归地将三维空间中的物体组织成层次化的树形结构来加速空间查询。每个节点包含一个包围盒,完全包含其所有子节点的包围盒,叶节点存储实际物体。BVH通过自顶向下的方式构建,在每一级将物体集合划分为两部分,递归处理直到达到停止条件,查询时仅需检查与查询区域相交的分支,大大减少了必要的检测次数,使查询复杂度从O(n)降低到接近O(log n)。
// BVH节点结构
struct BVHNode {PxBounds3 bounds; // 节点的包围盒BVHNode* left; // 左子节点BVHNode* right; // 右子节点int* primitiveIndices; // 叶节点包含的图元索引int primitiveCount; // 叶节点包含的图元数量bool isLeaf() const { return primitiveCount > 0; }
};// BVH类
class BVH {
private:BVHNode* mRoot; // 根节点std::vector<PxBounds3> mPrimitiveBounds; // 所有图元的包围盒std::vector<int> mPrimitiveIndices; // 图元索引public:// 构建BVHvoid build(const std::vector<PxBounds3>& primitiveBounds) {// 保存图元边界mPrimitiveBounds = primitiveBounds;// 初始化图元索引mPrimitiveIndices.resize(primitiveBounds.size());for (int i = 0; i < primitiveBounds.size(); i++) {mPrimitiveIndices[i] = i;}// 递归构建树mRoot = buildRecursive(0, primitiveBounds.size());}private:// 递归构建BVHBVHNode* buildRecursive(int start, int end) {BVHNode* node = new BVHNode();// 计算当前节点的包围盒node->bounds = computeBounds(start, end);// 计算图元数量int primitiveCount = end - start;// 叶节点条件:图元数量小于阈值if (primitiveCount <= 4) {// 创建叶节点node->primitiveCount = primitiveCount;node->primitiveIndices = new int[primitiveCount];for (int i = 0; i < primitiveCount; i++) {node->primitiveIndices[i] = mPrimitiveIndices[start + i];}node->left = node->right = nullptr;} else {// 找到最长轴int axis = findLongestAxis(node->bounds);// 按中间点划分int mid = start + primitiveCount / 2;std::nth_element(mPrimitiveIndices.begin() + start, mPrimitiveIndices.begin() + mid,mPrimitiveIndices.begin() + end,[&](int a, int b) {return getCentroid(mPrimitiveBounds[a])[axis] < getCentroid(mPrimitiveBounds[b])[axis];});// 递归构建子节点node->left = buildRecursive(start, mid);node->right = buildRecursive(mid, end);node->primitiveCount = 0;node->primitiveIndices = nullptr;}return node;}// 计算包围盒PxBounds3 computeBounds(int start, int end) {PxBounds3 bounds;bounds.setEmpty();for (int i = start; i < end; i++) {int index = mPrimitiveIndices[i];bounds.include(mPrimitiveBounds[index]);}return bounds;}// 查找最长轴int findLongestAxis(const PxBounds3& bounds) {PxVec3 extents = bounds.maximum - bounds.minimum;if (extents.x > extents.y && extents.x > extents.z) return 0;if (extents.y > extents.z) return 1;return 2;}// 获取中心点PxVec3 getCentroid(const PxBounds3& bounds) {return (bounds.minimum + bounds.maximum) * 0.5f;}public:// 射线查询void raycast(const PxVec3& origin, const PxVec3& direction, std::vector<int>& results) {if (!mRoot) return;// 创建射线Ray ray;ray.origin = origin;ray.direction = direction;ray.invDirection = PxVec3(1.0f/direction.x, 1.0f/direction.y, 1.0f/direction.z);// 递归查询raycastRecursive(mRoot, ray, results);}// 递归射线查询void raycastRecursive(BVHNode* node, const Ray& ray, std::vector<int>& results) {// 检查射线是否与节点包围盒相交if (!rayIntersectsAABB(ray, node->bounds)) return;if (node->isLeaf()) {// 叶节点,检查每个图元for (int i = 0; i < node->primitiveCount; i++) {int index = node->primitiveIndices[i];if (rayIntersectsPrimitive(ray, index)) {results.push_back(index);}}} else {// 内部节点,递归检查子节点raycastRecursive(node->left, ray, results);raycastRecursive(node->right, ray, results);}}
};
BVH算法是目前比较主流的宽相碰撞检测的算法,大大减小时间复杂度,在BVH算法的基础之上还有诸如动态AABB树等算法,我就不继续细说了(要细说的内容就太多了)。
宽相碰撞检测帮助我们在场景中筛选了哪些物体可能会发生碰撞,或者说宽相碰撞检测的核心意义在于快速排除明显不会发生碰撞的物体对,但是剩下的物体具体到底有没有发生碰撞呢?因为宽相碰撞检测再怎么说也不是精细地去判断碰撞检测:宽相通常使用简化的包围体(如AABB),这些包围体可能比实际几何形状大很多,导致"假阳性"结果;宽相只能告诉我们"这两个物体可能碰撞",但无法提供更精细的碰撞信息,而物理模拟需要精确的接触信息来计算力和冲量,仅凭宽相结果无法进行后续的物理计算。所以在这个时候,就需要我们的窄相碰撞检测去进行精准的碰撞检测了。
窄相碰撞检测
文件路径:
- physx/source/geomutils/src/contact/
- physx/source/lowlevel/software/src/PxsContactManager.cpp
GJK (Gilbert-Johnson-Keerthi)算法
GJK算法用于计算两个凸多面体之间的最近距离,通过迭代构建单纯形(Simplex)来逼近闵可夫斯基差(Minkowski Difference)的原点。它对于任意凸形状都有效,且计算效率高,是PhysX中处理复杂碰撞检测的基础算法。
这里可能已经有两陌生的专业名词难以理解了,我们来一个一个介绍:
单纯形是什么?
单纯形是n维空间中最简单的多面体,是每个维度空间里"最简单的封闭形状"。想象你用最少的点连成一个"封闭的空间",这就是单纯形。比如一维里是一条线段,二维里是一个三角形,三维里是一个立方体...规律是相同的:n维空间的单纯形需要n+1个点。
闵可夫斯基差是什么?
闵可夫斯基差是两个集合A和B的数学运算,定义为:
A ⊖ B = { a - b | a ∈ A, b ∈ B }
两个凸集合A和B相交,当且仅当它们的闵可夫斯基差包含原点,这个性质是GJK算法的理论基础。在PhysX中,闵可夫斯基差的计算通过支撑函数(Support Function)简化:
// 计算闵可夫斯基差的支撑点PxVec3 support(const ConvexShape& shapeA, const ConvexShape& shapeB, const PxVec3& direction) {// A形状在direction方向的最远点PxVec3 pointA = shapeA.getFarthestPointInDirection(direction);// B形状在-direction方向的最远点PxVec3 pointB = shapeB.getFarthestPointInDirection(-direction);// 返回闵可夫斯基差的支撑点return pointA - pointB;}
在GJK算法中,我们判断两个凸多面体是否相交的流程是这样的:
我们先随机选一个方向,分别获取一个凸多边形在这个方向的最远点以及另一个凸多边形反方向的最远点,进行坐标上的相减之后获取一个支撑点,把这个支撑点和原点连线的反方向作为新的方向继续迭代,这样最多迭代三到四次之后我们判断这几个点组成的单纯形是否包含原点就可以判断是否相交了。
这个过程看起来似乎有些不知所云,但其实其目的是把一个形状作为基准,来看从这个形状的某个点开始到达另一个形状的可能性,如果这两个形状有相交,那么我们的闵可夫斯基差就会包含原点。
// physx/source/geomutils/src/gjk/GuGJK.cpp
GjkStatus GJK::gjkPenetration(const GjkConvex& a, const GjkConvex& b,const Ps::aos::Vec3VArg initialDir,const Ps::aos::FloatVArg contactDist,Ps::aos::Vec3V& closestA, Ps::aos::Vec3V& closestB,Ps::aos::Vec3V& normal, Ps::aos::FloatV& depth)
{// 初始化单纯形SimplexV& simplex = mSimplex;simplex.reset();// 初始搜索方向Ps::aos::Vec3V Q = initialDir;Ps::aos::Vec3V A = a.supportSweep(Q, b);// 添加第一个顶点到单纯形simplex.addPoint(A, Q);// 迭代求解for(PxU32 i=0; i<MaxGJKIterations; i++){// 通过支撑点计算新的搜索方向Q = simplex.computeDirection();// 如果方向接近零,表示找到最近点if(Ps::aos::V3IsGrtr(epsilon, Ps::aos::V3Dot(Q, Q))){// 计算穿透深度和方向computePenetration(simplex, a, b, closestA, closestB, normal, depth);return GJK_CONTACT;}// 计算新的支撑点A = a.supportSweep(Q, b);// 添加到单纯形simplex.addPoint(A, Q);// 检查是否发现分离轴if(Ps::aos::FAllGrtr(epsilon, Ps::aos::V3Dot(A, Q))){return GJK_SEPARATED;}}return GJK_DEGENERATE;
}
EPA (Expanding Polytope Algorithm)算法
EPA算法在GJK确定两个物体相交后使用,通过逐步扩展多面体来精确计算穿透深度和穿透方向。它通过寻找闵可夫斯基差中距离原点最近的面,迭代扩展多面体,直到达到所需的精度。当GJK算法确定两个物体相交后,EPA算法接手继续计算更精确的碰撞信息。
更具体地来说,我们的EPA算法会继续使用GJK算法已经计算出的单纯形,先找出多面体上距离原点最近的面,然后沿该面的法线方向计算新的支撑点,如果新点比现有面更接近原点,将其添加到多面体中并更新多面体的面集合,一直重复此过程直到达到所需精度。
最近面到原点的距离就是穿透深度,面的法线就是穿透方向。
// physx/source/geomutils/src/gjk/GuEPA.cpp
PxU32 EPA::expandPolytope(const GjkConvex& a, const GjkConvex& b)
{// 初始化多面体PxU32 numFaces = 0;initializeFaceIndices(numFaces);// 扩展多面体for(PxU32 iteration = 0; iteration < MaxEPAFaceCount; iteration++){// 找到距离原点最近的面PxU32 closestFace = findClosestFace(numFaces);// 计算最近面的法线Ps::aos::Vec3V norm = mFaceNormals[closestFace];// 沿法线方向计算新的支撑点Ps::aos::Vec3V w = a.support(norm);Ps::aos::Vec3V p = b.support(Ps::aos::V3Neg(norm));Ps::aos::Vec3V newPoint = Ps::aos::V3Sub(w, p);// 检查是否达到精度要求Ps::aos::FloatV dist = Ps::aos::V3Dot(norm, newPoint);if(Ps::aos::FAllGrtr(mEpsilon, Ps::aos::FAdd(mClosestFaceDist, dist))){// 计算穿透深度和方向computePenetration(closestFace, mClosestFaceDist);return EPA_CONTACT;}// 扩展多面体expandFace(closestFace, newPoint, numFaces);}return EPA_DEGENERATE;
}
基本几何形状专用算法
PhysX为常见的几何形状对实现了高度优化的专用碰撞算法,如球-球、球-平面、盒-盒等。这些算法利用特定几何形状的数学特性,提供了比通用算法更高效的碰撞检测。
我这里就简单介绍一下概念吧,不贴代码了,相信明白原理之后你也可以写出代码:
球-球:两个球体相交当且仅当它们的球心距离小于半径之和。
球-平面:球体与平面相交当且仅当球心到平面的距离小于球体半径。平面方程一般是 n·x + d = 0,把x带入球心坐标就行。
球-盒:找出盒子上距离球心最近的点,然后检查该点与球心的距离是否小于球体半径。当然,显然问题是如何找出盒子上距离球心最近的点。我们首先要将坐标系转换为以盒为原点的局部坐标系,然后对每个坐标轴(x,y,z)分别执行以下操作:如果球心在该轴上的坐标大于盒子半长,最近点在该轴上的坐标就是盒子半长;如果球心在该轴上的坐标小于负盒子半长,最近点在该轴上的坐标就是负盒子半长;如果球心在该轴上的坐标在盒子范围内,最近点在该轴上的坐标就与球心相同。
胶囊-胶囊:计算两条线段(胶囊轴)之间的最近点,然后检查这两点之间的距离是否小于半径之和。如何计算三维空间中的两条线段之间的最近点?先判断两线段是否平行或重合,若平行或重合,可在一条直线上取固定点计算到另一条直线的距离;若不平行,通过向量运算和求解线性方程组得到两线段上最近点的参数,若参数在[0,1]内,则对应点为最近点,若不在则考虑端点投影,取距离最小的情况作为结果。
盒-盒:我们采取分离轴定理:简单地说,两个不相交的凸集必然存在一个分离轴,使两个凸集在该轴上的投影是分离的,若在所有可能的分离轴上,两个形状的投影都有重叠,则这两个图形相交。一般来说我们只会去判断它们边的法向量以及面的法向量作为分离轴。
连续碰撞检测(CCD)
传统的碰撞检测(宽相+窄相)(也就是离散碰撞检测)在每个物理时间步的末尾检查物体是否相交,这会导致"穿透问题",高速移动的物体可能在一个时间步内完全穿过薄物体,由于采样时物体已经穿过又或者传统碰撞检测无法捕获这种碰撞,于是导致了最常见的游戏中子弹穿墙、角色掉出地面等现象。
这个时候就需要我们的连续碰撞检测来完善这个问题了,通过模拟物体在整个时间步内的连续运动:确保高速物体不会"跳过"薄物体;减少因突然碰撞导致的物理不稳定;计算物体何时何地首次接触,而不仅是时间步结束时的状态;:允许游戏中的高速物理互动,如射击、快速移动物体等。
具体的执行流程如下:
物理模拟 → 离散碰撞 → CCD检测 → 物理更新:首先CCD一定是在离散碰撞检测发生之后再执行的,作为离散碰撞的补充与改善。执行CCD在PhysX中有一系列配置操作,这个过程我就不说了,就是去改一些bool变量,配置一下上下文什么的,不是很重要,我们把目光放在主要的CCD检测的流程中。
可以把CCD分为这么几个步骤:碰撞对筛选,扫描测试,岛屿构建和碰撞响应。
碰撞对筛选
碰撞对筛选负责从所有可能的物体对中快速识别出需要进行CCD处理的对象。这个步骤至关重要,因为CCD计算成本高,只应用于真正需要的物体(如高速移动物体)。
文件位置:physx/source/lowlevel/software/src/PxsCCD.cpp
physx/source/lowlevel/software/src/PxsCCDBody.h
CCD的碰撞对筛选就是一个不断精细的过滤过程,不断增加过滤条件留下具体需要去CCD处理的物体。首先是基于标志和速度的初步筛选,首先我们只检测开启了CCD标志的刚体,然后我们去获取设置的速度阈值(包括线性速度和角速度)以及形状尺寸阈值(当物体在一定时间内移动的距离超过这个尺寸阈值我们启动CCD),只要超过这个阈值我们就把物体加入CCD队列(是的,CCD处理的数据结构是优先队列,队列按碰撞发生时间(TOI)排序,最早发生的碰撞最先处理);
// 位于 physx/source/lowlevel/software/src/PxsCCD.cpp
void PxsCCDContext::filterBodies(PxReal dt)
{// 遍历所有刚体for(PxU32 i=0; i<mRigidBodies.size(); i++){PxsRigidBody* body = mRigidBodies[i];// 1. 只考虑启用了CCD的动态刚体if(body->isKinematic() || !(body->getFlags() & PxRigidBodyFlag::eENABLE_CCD))continue;// 2. 计算CCD激活阈值const PxVec3 linearVelocity = body->getLinearVelocity();const PxReal speed = linearVelocity.magnitude();// 获取形状的最小尺寸PxReal minExtent = computeMinShapeExtent(body);// 3. 应用CCD激活公式:// - 如果物体在一个时间步内移动距离超过其最小尺寸的一定比例,激活CCD// - 阈值系数可调整(0.02-0.05是常见值)if(speed * dt < minExtent * mCCDThreshold)continue;// 4. 考虑角速度// 高角速度也可能导致快速运动,尤其是对于长形状const PxVec3 angularVelocity = body->getAngularVelocity();const PxReal angularSpeed = angularVelocity.magnitude();// 计算形状的最大半径PxReal maxRadius = computeMaxShapeRadius(body);// 如果角速度导致的线速度足够大,也激活CCDif(angularSpeed * maxRadius * dt >= minExtent * mCCDThreshold){// 添加到CCD处理队列addBodyToCCD(body, i);}}
}
PhysX还使用专门的空间分区结构来加速CCD碰撞对的识别,比如下图中的空间哈希:我们考虑被打上CCD标志的物体的整个运动轨迹中是否与其他物体发生碰撞——这个过程使用空间哈希来优化查找过程,然后对于在物体运动轨迹中的空间哈希对应的空间块中其他物体启用CCD计算。
// 位于 physx/source/lowlevel/software/src/PxsCCDSpatialHash.cpp
void PxsCCDSpatialHash::buildHashTable()
{// 1. 确定空间哈希表大小(通常是质数)const PxU32 hashSize = 16381; // 2^14-3mHashTable.resize(hashSize);// 2. 计算适合的单元格大小// - 太小:很多物体跨越多个单元格,效率低// - 太大:每个单元格包含太多物体,失去分区优势PxReal avgObjectSize = computeAverageObjectSize();mCellSize = avgObjectSize * 2.0f; // 经验值// 3. 将物体插入哈希表for(PxU32 i=0; i<mCCDBodies.size(); i++){PxsCCDBody& body = mCCDBodies[i];PxBounds3 bounds = computeExpandedBounds(body.mBody);// 计算包围盒覆盖的单元格范围PxI32 minX = (PxI32)floor(bounds.minimum.x / mCellSize);PxI32 minY = (PxI32)floor(bounds.minimum.y / mCellSize);PxI32 minZ = (PxI32)floor(bounds.minimum.z / mCellSize);PxI32 maxX = (PxI32)floor(bounds.maximum.x / mCellSize);PxI32 maxY = (PxI32)floor(bounds.maximum.y / mCellSize);PxI32 maxZ = (PxI32)floor(bounds.maximum.z / mCellSize);// 遍历所有覆盖的单元格for(PxI32 z = minZ; z <= maxZ; z++){for(PxI32 y = minY; y <= maxY; y++){for(PxI32 x = minX; x <= maxX; x++){// 计算空间哈希值PxU32 hashValue = computeHashValue(x, y, z, hashSize);// 将物体添加到对应的哈希桶mHashTable[hashValue].add(&body);}}}}
}// 构建潜在CCD碰撞对
void PxsCCDContext::buildPotentialPairs()
{// 使用空间哈希表识别潜在碰撞对for(PxU32 i=0; i<mSpatialHash.mHashTable.size(); i++){HashBucket& bucket = mSpatialHash.mHashTable[i];// 检查同一桶内的所有物体对for(PxU32 j=0; j<bucket.size(); j++){PxsCCDBody* bodyA = bucket[j];for(PxU32 k=j+1; k<bucket.size(); k++){PxsCCDBody* bodyB = bucket[k];// 执行更精细的AABB重叠测试if(aabbOverlap(bodyA, bodyB)){// 创建CCD碰撞对createCCDPair(bodyA, bodyB);}}}}
}
扫描测试
扫描测试计算物体沿其运动轨迹是否与其他物体相交,以及首次相交的时间和位置。这是CCD的核心算法,通过模拟物体在整个时间步内的连续运动来检测可能被离散检测遗漏的碰撞。
文件位置:physx/source/lowlevel/software/src/PxsCCDSweep.cpp
physx/source/geomutils/src/sweep/GuSweepTests.cpp
其实CCD的扫描测试涉及到的算法和我们之前在窄相碰撞检测中涉及到的算法大差不差,CCD的扫描测试和窄相碰撞检测的区别主要在于:
简言之,扫描测试是将窄相碰撞检测扩展到时间维度的版本,不仅要知道"是否碰撞",还要知道"何时碰撞"。
岛屿构建
岛屿构建将相互关联的CCD物体分组,以便进行本地化处理。通过识别彼此交互的物体集合,CCD系统可以更高效地处理碰撞,避免不必要的全局计算。
文件位置:physx/source/lowlevel/software/src/PxsCCDContext.cpp
CCD的岛屿构建与离散碰撞中的岛屿系统类似,但增加了时间维度的考量:
// 简化的岛屿构建过程
void buildCCDIslands(PxsCCDContext* context)
{// 1. 按TOI(碰撞时间)排序碰撞对context->sortPairsByTimeOfImpact();// 2. 初始化岛屿context->mIslands.clear();// 3. 为每个碰撞对创建连接关系for(PxU32 i=0; i<context->mCCDPairs.size(); i++){CCDPair& pair = context->mCCDPairs[i];// 使用并查集(Union-Find)算法连接相关物体PxU32 islandIdA = context->findIsland(pair.mBodyA);PxU32 islandIdB = context->findIsland(pair.mBodyB);if(islandIdA != islandIdB)context->mergeIslands(islandIdA, islandIdB);}// 4. 提取最终岛屿context->extractIslands();
}
碰撞响应
碰撞响应处理CCD碰撞的物理结果,包括计算冲量、更新物体的位置和速度,以及确保物体不会相互穿透。它按照碰撞发生的时间顺序依次处理,确保多碰撞场景中的物理行为合理。
文件路径:physx/source/lowlevel/software/src/PxsCCDContactResponse.cpp
// 简化的CCD碰撞响应过程
void processCCDContact(CCDPair& pair, PxReal toi)
{// 1. 回滚到碰撞时刻PxTransform poseA = interpolatePose(pair.mBodyA, toi);PxTransform poseB = interpolatePose(pair.mBodyB, toi);// 2. 计算接触点信息ContactPoint contact;computeCCDContactPoint(pair, poseA, poseB, contact);// 3. 应用碰撞响应(计算冲量)PxVec3 impulse = computeCCDImpulse(pair, contact);// 4. 更新速度if(!pair.mBodyA->isStatic())updateBodyVelocity(pair.mBodyA, impulse, contact.point);if(!pair.mBodyB->isStatic())updateBodyVelocity(pair.mBodyB, -impulse, contact.point);// 5. 前进模拟到下一个碰撞点或时间步结束advanceToNextTimeStep(pair.mBodyA, pair.mBodyB, toi);
}
大体总结一下就是:首先将物体状态精确回滚到碰撞发生的时刻(TOI);然后在该时刻计算接触点和法线;接着基于物体材料属性计算反弹冲量,将其分解为法向冲量(处理反弹)和切向冲量(处理摩擦);随后直接修改物体速度,而非通过迭代约束求解;最后使用新速度积分剩余时间步,更新物体最终位置。
场景查询系统(Scene Query System)
场景查询系统(Scene Query System)是PhysX中的核心功能模块,负责执行各种空间查询操作,如射线投射、形状扫描和重叠测试。这个系统提供了一种高效方式来探测物理世界,是游戏AI、用户交互和物理模拟之间的重要桥梁。
射线投射(Raycast)
PxRaycastBuffer hit;
bool isHit = scene->raycast(origin, direction, maxDistance, hit);
射线投射从一个点发出一条射线,检测与场景对象的首个交点。
扫描测试(Sweep)
PxSweepBuffer sweepResults;
bool isSweepHit = scene->sweep(geometryBox, pose, direction, distance, sweepResults);
扫描测试是沿某方向移动一个几何体,检测与场景对象的碰撞。
重叠测试(Overlap)
PxOverlapBuffer overlapResults;
bool isOverlapping = scene->overlap(geometrySphere, pose, overlapResults);
重叠测试检查一个几何体与场景中哪些对象相交。
岛屿系统(Island System)
岛屿系统是PhysX中的一个关键组件,负责将相互接触或约束连接的物体分组成相互独立的"岛屿"。一个岛屿是一组通过接触或约束相互影响的物体集合。
// 岛屿生成的核心算法
void generateIslands(PxScene* scene)
{// 1. 初始化岛屿构建器scene->mIslandManager.reset();// 2. 添加所有活跃物体和约束for(PxU32 i=0; i<scene->mActiveActors.size(); i++)scene->mIslandManager.addActor(scene->mActiveActors[i]);// 3. 添加所有活跃接触对for(PxU32 i=0; i<scene->mActiveContacts.size(); i++)scene->mIslandManager.addContact(scene->mActiveContacts[i]);// 4. 执行岛屿构建scene->mIslandManager.buildIslands();
}// 岛屿构建算法
void IslandManager::buildIslands()
{// 使用并查集算法(Union-Find)将相关物体合并到同一岛屿for(PxU32 i=0; i<mContacts.size(); i++){Contact& contact = mContacts[i];RigidBody* bodyA = contact.bodyA;RigidBody* bodyB = contact.bodyB;// 静态物体不参与岛屿构建if(bodyA->isStatic() && bodyB->isStatic())continue;// 合并岛屿PxU32 islandA = bodyA->islandId;PxU32 islandB = bodyB->islandId;if(islandA != islandB)mergeIslands(islandA, islandB);}// 类似处理约束...// 提取最终岛屿extractFinalIslands();
}
岛屿系统的核心无非是基于图论算法的构建岛屿的过程(bfs或dfs或并查集),后续我们只需要去根据构建出的岛屿对象进行一些优先级上的排序即可。 但是岛屿系统的最大意义其实是:并行计算。
为什么现在的游戏大多依赖于GPU而不是CPU?那我们首先要明白CPU和GPU的结构性上的差异:
ok,大体意思其实就是对于CPU来说,读懂机器的复杂序列指令更重要,所以用来存储指令的缓存更重要,一个CPU内部的缓存的大小比重就不会很小;而对于GPU来说不用去考虑读取指令的事,他就像王牌打工人一样,只用不断增加自己的算术处理单元即可。
那岛屿系统具体来说如何提高计算效率呢?和并行计算有什么关系呢?
岛屿系统通过将物理场景中的物体合理划分为多个独立的“岛屿”,每个岛屿内的物体相互作用相对独立,减少了不同岛屿间的数据依赖和通信开销,从而实现任务的并行处理。在并行计算中,多核处理器或GPU可同时对这些岛屿进行计算,充分利用硬件资源,显著提高计算效率。
物理求解层
约束求解器
PhysX物理求解层是引擎的核心组件,主要负责处理约束求解和物体运动。
文件路径:
- DyDynamics.h/cpp - 基本求解系统和上下文
- DyPGS.h - PGS求解器接口定义
- DyTGS.h - TGS求解器接口定义
- DySolverConstraints.cpp - 约束求解实现
- DyConstraintSetup.cpp - 约束设置
- DySolverCore.h - 求解器核心接口
物理求解的核心思想是将物理问题转化为数学约束求解问题:将物理规则(如不穿透、关节限制)表示为数学约束。
PGS(投影高斯-赛德尔法)
PGS是传统的约束求解方法,以速度为核心变量进行迭代求解,在物理模拟的过程中,我们会根据当前的物体速度判断是否满足约束进行多次迭代计算(有多重约束的话),如果不满足的话求解器会返回一个冲量到物体上实现瞬间改变物体速度的效果以满足约束的效果。
文件路径:physx\source\lowleveldynamics\src\DySolverConstraints.cpp
void solveExt1D(const PxSolverConstraintDesc& desc, Vec3V& linVel0, Vec3V& linVel1...)
{// 遍历约束行for (PxU32 i=0; i<count; ++i, base++){// 计算相对速度const Vec3V v0 = V3MulAdd(linVel0, lin0, V3Mul(angVel0, ang0));const Vec3V v1 = V3MulAdd(linVel1, lin1, V3Mul(angVel1, ang1));const FloatV normalVel = V3SumElems(V3Sub(v0, v1));// 计算冲量const FloatV unclampedForce = FScaleAdd(iMul, appliedForce, FScaleAdd(vMul, normalVel, constant));// 投影冲量(约束限制)const FloatV clampedForce = FMin(maxImpulse, (FMax(minImpulse, unclampedForce)));const FloatV deltaF = FSub(clampedForce, appliedForce);// 更新速度// ...}
}
其实这个求解过程中真正核心的部分是判断物体的速度是否满足约束,这是一些偏数学的内容。
首先通过雅可比矩阵J将物体的速度v映射到约束空间,形成表达式J·v+b,其中b是与位置误差相关的偏置项;若该表达式等于0(等式约束)或大于等于0(不等式约束),则速度满足约束;若不满足,则计算所需的拉格朗日乘子λ(即约束冲量)来修正速度,修正方式为v_new = v + M^-1·J^T·λ,其中M^-1是质量矩阵的逆,J^T将约束空间的冲量转换回物体空间;这个过程会迭代多次,直到所有约束近似满足。
既然我们不是相关专业的学生,我也不指望在具体数学层面进行优化,我也就不展开说了。
TGS(时间高斯-赛德尔法)
TGS是PGS的改进版,引入了时间维度,同时求解位置和速度约束。
文件路径:physx\source\lowleveldynamics\src\DyTGSContactPrep.cpp
TGS(时间高斯-赛德尔)与PGS的关键区别在于:TGS将时间步长分解为多个子步,在每个子步中交替求解速度和位置约束;它直接将位置误差(穿透深度)作为约束条件处理,而不仅通过偏置项间接影响速度;TGS在约束求解过程中进行中间积分,使每次迭代的结果立即反映在位置上,形成"求解-积分-求解"的循环;这种方法虽然计算成本更高,但能更精确地处理硬约束(如关节)和大时间步长下的接触,减少弹跳和穿透问题,保持更好的能量守恒性,适合要求高精度物理模拟的场景。
说实话,其实没咋看懂,我的评价是如果想改进优化这部分的内容,不是数学或者物理专业的学生感觉有点难。
从整体来看,PGS仍是最广泛使用的方法,因为它提供了良好的性能-精度平衡,而TGS在对稳定性和精度要求更高的场景中正逐渐普及,目前我们先知道有这两种约束求解器,知道他们干的事就行了。
物体类型层
这个层就是我们对于具体的物理物体的构建了,我们在这个层级实现了可以涵盖大多数情境的物理物体。
刚体系统
什么是刚体呢?刚体就是无论受怎样的外部力内部都不会产生形变的物体,这让我们去计算刚体的内容时非常方便:我们只需要去考虑刚体的六个自由度(x,y,z轴的平移和旋转)即可。
相关文件路径:
- physx/include/PxRigidBody.h - 刚体基类
- physx/include/PxRigidDynamic.h - 动态刚体
- physx/include/PxRigidStatic.h - 静态刚体
- physx/source/physx/src/NpRigidDynamic.cpp - 实现文件
- physx/source/physx/src/NpRigidStatic.cpp - 实现文件
我们主要实现了三种刚体:静态刚体、动态刚体和运动学刚体。
PxBase↓
PxActor↓
PxRigidActor (基础刚体Actor)↓├── PxRigidStatic (静态刚体)└── PxRigidBody (带有物理属性的刚体基类)↓└── PxRigidDynamic (动态刚体)
这是PhysX中不同刚体实现的结构。
class PxRigidBody : public PxRigidActor
{
public:// 设置质心局部位置virtual void setCMassLocalPose(const PxTransform& pose) = 0;virtual PxTransform getCMassLocalPose() const = 0;// 质量设置virtual void setMass(PxReal mass) = 0;virtual PxReal getMass() const = 0;virtual PxReal getInvMass() const = 0;// 惯性张量设置virtual void setMassSpaceInertiaTensor(const PxVec3& m) = 0;virtual PxVec3 getMassSpaceInertiaTensor() const = 0;virtual PxVec3 getMassSpaceInvInertiaTensor() const = 0;// 阻尼系数virtual void setLinearDamping(PxReal linDamp) = 0;virtual PxReal getLinearDamping() const = 0;virtual void setAngularDamping(PxReal angDamp) = 0;virtual PxReal getAngularDamping() const = 0;// 速度virtual PxVec3 getLinearVelocity() const = 0;virtual PxVec3 getAngularVelocity() const = 0;// 速度限制virtual void setMaxLinearVelocity(PxReal maxLinVel) = 0;virtual PxReal getMaxLinearVelocity() const = 0;virtual void setMaxAngularVelocity(PxReal maxAngVel) = 0;virtual PxReal getMaxAngularVelocity() const = 0;// 力和扭矩virtual void addForce(const PxVec3& force, PxForceMode::Enum mode = PxForceMode::eFORCE, bool autowake = true) = 0;virtual void addTorque(const PxVec3& torque, PxForceMode::Enum mode = PxForceMode::eFORCE, bool autowake = true) = 0;virtual void clearForce(PxForceMode::Enum mode = PxForceMode::eFORCE) = 0;virtual void clearTorque(PxForceMode::Enum mode = PxForceMode::eFORCE) = 0;virtual void setForceAndTorque(const PxVec3& force, const PxVec3& torque, PxForceMode::Enum mode = PxForceMode::eFORCE) = 0;// 刚体标志virtual void setRigidBodyFlag(PxRigidBodyFlag::Enum flag, bool value) = 0;virtual void setRigidBodyFlags(PxRigidBodyFlags inFlags) = 0;virtual PxRigidBodyFlags getRigidBodyFlags() const = 0;
};
这是基础刚体类需求的属性。 在这里稍微解释一下各个参数的含义:质量和惯性张量能决定刚体质量分布,影响它受力时咋运动、咋转动。阻尼系数能模拟空气阻力的耗散效应。施加力和扭矩的方法,能让刚体受到推力、扭力,实现推动、旋转这些效果。刚体标志属性能控制刚体受不受重力、是不是运动学刚体这些行为模式。
静态刚体 (PxRigidStatic)
静态刚体的实现非常简单,毕竟又不用动,本身又不会形变,在底层存储为简单的碰撞形状和变换信息,在物理求解过程中被视为不可移动的约束。
// 静态刚体非常简单,几乎没有额外的方法,仅继承自PxRigidActor
class PxRigidStatic : public PxRigidActor
{
public:virtual const char* getConcreteTypeName() const PX_OVERRIDE PX_FINAL { return "PxRigidStatic"; }protected:PX_INLINE PxRigidStatic(PxType concreteType, PxBaseFlags baseFlags) : PxRigidActor(concreteType, baseFlags) {}PX_INLINE PxRigidStatic(PxBaseFlags baseFlags) : PxRigidActor(baseFlags){}virtual ~PxRigidStatic() {}virtual bool isKindOf(const char* name) const { PX_IS_KIND_OF(name, "PxRigidStatic", PxRigidActor); }
};
动态刚体 (PxRigidDynamic)
动态刚体当然要受其他力的影响,如重力、碰撞、力和冲量等,还具有质量、惯性张量、速度、加速度等物理属性;针对动态刚体,我们一般还会加入睡眠机制来设置刚体进入睡眠状态以减小计算量。
class PxRigidDynamic : public PxRigidBody
{
public:// 运动学目标virtual void setKinematicTarget(const PxTransform& destination) = 0;virtual bool getKinematicTarget(PxTransform& target) const = 0;// 睡眠相关virtual bool isSleeping() const = 0;virtual void setSleepThreshold(PxReal threshold) = 0;virtual PxReal getSleepThreshold() const = 0;virtual void setWakeCounter(PxReal wakeCounterValue) = 0;virtual PxReal getWakeCounter() const = 0;virtual void wakeUp() = 0;virtual void putToSleep() = 0;// 稳定性阈值virtual void setStabilizationThreshold(PxReal threshold) = 0;virtual PxReal getStabilizationThreshold() const = 0;// 轴向锁定virtual PxRigidDynamicLockFlags getRigidDynamicLockFlags() const = 0;virtual void setRigidDynamicLockFlag(PxRigidDynamicLockFlag::Enum flag, bool value) = 0;virtual void setRigidDynamicLockFlags(PxRigidDynamicLockFlags flags) = 0;// 速度控制virtual void setLinearVelocity(const PxVec3& linVel, bool autowake = true) = 0;virtual void setAngularVelocity(const PxVec3& angVel, bool autowake = true) = 0;// 求解器迭代次数virtual void setSolverIterationCounts(PxU32 minPositionIters, PxU32 minVelocityIters = 1) = 0;virtual void getSolverIterationCounts(PxU32& minPositionIters, PxU32& minVelocityIters) const = 0;
};
在NpRigidDynamic.cpp中是这样实现动态刚体的:
// 设置线性速度
void NpRigidDynamic::setLinearVelocity(const PxVec3& velocity, bool autowake)
{NpScene* npScene = getNpScene();NP_WRITE_CHECK(npScene);PX_CHECK_AND_RETURN(velocity.isFinite(), "PxRigidDynamic::setLinearVelocity: velocity is not valid.");PX_CHECK_AND_RETURN(!(mCore.getFlags() & PxRigidBodyFlag::eKINEMATIC), "PxRigidDynamic::setLinearVelocity: Body must be non-kinematic!");PX_CHECK_AND_RETURN(!(mCore.getActorFlags().isSet(PxActorFlag::eDISABLE_SIMULATION)), "PxRigidDynamic::setLinearVelocity: Not allowed if PxActorFlag::eDISABLE_SIMULATION is set!");PX_CHECK_SCENE_API_WRITE_FORBIDDEN_EXCEPT_SPLIT_SIM(npScene, "PxRigidDynamic::setLinearVelocity() not allowed while simulation is running. Call will be ignored.")scSetLinearVelocity(velocity);if(npScene && npScene->getFlagsFast() & PxSceneFlag::eENABLE_BODY_ACCELERATIONS){const PxU32 index = getRigidActorArrayIndex();if(index>=npScene->mRigidDynamicsAccelerations.size())npScene->mRigidDynamicsAccelerations.resize(index+1);npScene->mRigidDynamicsAccelerations[index].mPrevLinVel = velocity;}if(npScene)wakeUpInternalNoKinematicTest((!velocity.isZero()), autowake);
}// 设置运动学目标
void NpRigidDynamic::setKinematicTarget(const PxTransform& destination)
{NpScene* npScene = getNpScene();NP_WRITE_CHECK(npScene);PX_CHECK_AND_RETURN(destination.isSane(), "PxRigidDynamic::setKinematicTarget: destination is not valid.");PX_CHECK_AND_RETURN(npScene, "PxRigidDynamic::setKinematicTarget: Body must be in a scene!");PX_CHECK_AND_RETURN((mCore.getFlags() & PxRigidBodyFlag::eKINEMATIC), "PxRigidDynamic::setKinematicTarget: Body must be kinematic!");PX_CHECK_AND_RETURN(!(mCore.getActorFlags().isSet(PxActorFlag::eDISABLE_SIMULATION)), "PxRigidDynamic::setKinematicTarget: Not allowed if PxActorFlag::eDISABLE_SIMULATION is set!");PX_CHECK_SCENE_API_WRITE_FORBIDDEN_EXCEPT_SPLIT_SIM(npScene, "PxRigidDynamic::setKinematicTarget() not allowed while simulation is running. Call will be ignored.")// 将目标从Actor空间转换到Body空间const PxTransform bodyTarget = destination.getNormalized() * mCore.getBody2Actor();// 设置内部目标scSetKinematicTarget(bodyTarget);// 更新场景查询(如果需要)if(mCore.getFlags() & PxRigidBodyFlag::eUSE_KINEMATIC_TARGET_FOR_SCENE_QUERIES)mShapeManager.markActorForSQUpdate(npScene->getSQAPI(), *this);
}// 休眠处理
void NpRigidDynamic::putToSleep()
{NpScene* npScene = getNpScene();NP_WRITE_CHECK(npScene);PX_CHECK_AND_RETURN(npScene, "PxRigidDynamic::putToSleep: Body must be in a scene!");PX_CHECK_AND_RETURN(!(mCore.getFlags() & PxRigidBodyFlag::eKINEMATIC), "PxRigidDynamic::putToSleep: Body must be non-kinematic!");PX_CHECK_AND_RETURN(!(mCore.getActorFlags().isSet(PxActorFlag::eDISABLE_SIMULATION)), "PxRigidDynamic::putToSleep: Not allowed if PxActorFlag::eDISABLE_SIMULATION is set!");PX_CHECK_SCENE_API_WRITE_FORBIDDEN_EXCEPT_SPLIT_SIM(npScene, "PxRigidDynamic::putToSleep() not allowed while simulation is running. Call will be ignored.")scPutToSleep();
}
运动学刚体 (Kinematic RigidBody)
运动学刚体和动态刚体是两种同时共享刚体基本属性但是运作方式不同的刚体,运动学刚体更多是接受用户的代码控制来运作的刚体而不是接收物理世界中的物理法则,这方便我们实现更精细的操作。
// 设置运动学目标
void NpRigidDynamic::setKinematicTarget(const PxTransform& destination)
{// 参数检查...// 将目标从Actor空间转换到Body空间const PxTransform bodyTarget = destination * mCore.getBody2Actor();// 设置内部目标scSetKinematicTarget(bodyTarget);// 更新场景查询(如果需要)if(mCore.getFlags() & PxRigidBodyFlag::eUSE_KINEMATIC_TARGET_FOR_SCENE_QUERIES)mShapeManager.markActorForSQUpdate(scene->getSQAPI(), *this);
}
变形体系统
然后是我们的变形体系统,也就是所谓的可变形体。
PxActor↓
PxDeformableBody (变形体基类)↓├── PxDeformableVolume (可变形体积)└── PxDeformableSurface (可变形表面)
结构如下,相关的文件路径:
API层级:
- physx/include/PxDeformableBody.h - 变形体基类接口
- physx/include/PxDeformableVolume.h - 可变形体积接口
- physx/include/PxDeformableSurface.h - 可变形表面接口
实现层级:
- physx/source/physx/src/NpDeformableVolume.cpp - 高层实现
- physx/source/simulationcontroller/src/ScDeformableVolumeCore.cpp - 场景控制层
- physx/source/lowleveldynamics/include/DyDeformableVolumeCore.h - 底层核心
相关基类:
struct DeformableBodyCore
{// 基础物理属性PxReal sleepThreshold; // 睡眠阈值PxU16 solverIterationCounts; // 求解器迭代次数PxReal wakeCounter; // 唤醒计数器bool dirty; // 脏标志// 变形体特有属性PxDeformableBodyFlags bodyFlags; // 变形体标志PxReal linearDamping; // 线性阻尼PxReal maxLinearVelocity; // 最大线性速度PxReal maxPenetrationBias; // 最大穿透偏置PxActorFlags actorFlags; // Actor标志
};
sleepThreshold决定变形体何时休眠以节省计算资源,solverIterationCounts影响物理计算的精确度,wakeCounter管理变形体的唤醒状态,dirty标记是否需要重新计算物理状态;bodyFlags控制变形体特有的行为(如自碰撞),linearDamping模拟运动阻力,maxLinearVelocity限制最大速度,maxPenetrationBias控制碰撞穿透修正量;而actorFlags则定义通用的物理行为(如重力影响和碰撞检测)
然后是变形体实现的核心思路:双网格。
双网格
什么是双网格?为什么需要双网格?刚体不用?
双网格系统是PhysX变形体的核心设计,它将变形体的表示分为两个独立但相关的网格,以平衡性能和精度。在变形体模拟中存在一个根本矛盾:碰撞检测需要相对简单的网格以保证性能而物理计算需要足够精细的网格以保证精度,于是解决方案就是我们生成两个网格分别来处理这两个问题。为什么刚体不用呢?因为刚体自身不会形变,它只需要简单的碰撞检测网格而不需要复杂的物理计算网格。
bool NpDeformableVolume::attachShape(PxShape& shape)
{// 验证必须是四面体网格几何const PxTetrahedronMeshGeometry& tetGeom = static_cast<const PxTetrahedronMeshGeometry&>(npShape->getGeometry());Gu::BVTetrahedronMesh* tetMesh = static_cast<Gu::BVTetrahedronMesh*>(tetGeom.tetrahedronMesh);// 为碰撞网格分配GPU内存const PxU32 numVerts = tetMesh->getNbVerticesFast();core.positionInvMass = allocateDeviceMemory(numVerts * sizeof(PxVec4));core.restPosition = allocateDeviceMemory(numVerts * sizeof(PxVec4));// 碰撞网格用于:// 1. 与其他物体的碰撞检测// 2. 场景查询 (射线投射等)// 3. 渲染显示
}
bool NpDeformableVolume::attachSimulationMesh(PxTetrahedronMesh& simulationMesh, PxDeformableVolumeAuxData& auxData)
{Gu::TetrahedronMesh& tetMesh = static_cast<Gu::TetrahedronMesh&>(simulationMesh);const PxU32 numVertsGM = tetMesh.getNbVerticesFast();// 为模拟网格分配GPU内存core.simPositionInvMass = allocateDeviceMemory(numVertsGM * sizeof(PxVec4));core.simVelocity = allocateDeviceMemory(numVertsGM * sizeof(PxVec4));// 模拟网格用于:// 1. XPBD约束求解// 2. 内力计算 (弹性力、阻尼力)// 3. 自碰撞检测// 4. 质量分布计算
}
这是两个网格的大体实现,一个用于碰撞检测一个用于具体的物理计算。
但是要详细地介绍完这个双网格系统,我想我们需要做的事很多:
首先我们来介绍一下网格系统:
// PhysX中四面体网格的核心数据结构
struct TetrahedronMeshData {PxU32 mNbVertices; // 顶点数量PxVec3* mVertices; // 顶点位置数组PxU32 mNbTetrahedrons; // 四面体数量void* mTetrahedrons; // 四面体索引数组(可以是16位或32位)PxU16* mMaterialIndices; // 每个四面体的材料索引PxBounds3 mAABB; // 包围盒PxReal mGeomEpsilon; // 几何精度
};// 四面体索引结构
template<class T>
struct TetrahedronT {T v[4]; // 四个顶点的索引// 例如:v = [0, 1, 2, 3] 表示由顶点0,1,2,3构成的四面体
};
PhysX引擎中的网格就是一个个四面体。
双网格系统的核心工作流程是:
- 预处理阶段:建立两个网格之间的映射关系(重心坐标插值)
- 模拟阶段:在高精度模拟网格上执行物理计算
- 同步阶段:将模拟结果插值到低精度碰撞网格
- 碰撞阶段:使用碰撞网格进行高效的碰撞检测
- 反馈阶段:将碰撞产生的力传播回模拟网格
XPBD求解器
XPBD (eXtended Position Based Dynamics) 是PBD (Position Based Dynamics) 的扩展版本,它是一种基于位置的物理模拟方法,是一种通用的物理求解器。
// XPBD的核心思想:直接修正位置而非速度
// 传统方法:修正速度 → 积分得到位置
// XPBD方法:直接修正位置 → 通过位置差分计算速度// 位置修正的基本公式
PxVec3 deltaPos = -constraint.error * constraint.compliance / constraint.mass;
position += deltaPos;
velocity = deltaPos / dt; // 速度通过位置变化计算
XPBD的核心优势在于其稳定性和对软约束的良好处理能力,这使得它特别适合变形体模拟。
粒子系统
PhysX的粒子系统是一个基于GPU加速的高性能物理模拟系统,主要用于模拟流体、颗粒物质、布料等复杂物理现象。
其特点包括:
- GPU加速:完全在GPU上运行,支持大规模粒子模拟
- PBD求解器:使用Position Based Dynamics进行物理计算
- 多种粒子类型:支持流体、固体、气体等不同类型的粒子
- 高度可扩展:支持自定义材料和行为
流体
PhysX中的流体实现基于SPH(光滑粒子流体动力学)方法,将流体离散化为大量粒子,每个粒子携带密度、速度、压力等物理属性;系统使用GPU并行计算,通过CUDA核函数在每个时间步中计算粒子间的相互作用力(包括压力力、粘性力、表面张力等),这些力的计算依赖于核函数来定义粒子的影响范围和权重分布;粒子缓冲区系统负责在CPU和GPU之间高效传输和管理粒子数据,支持动态添加/删除粒子;空间哈希表用于加速邻居粒子搜索,避免O(n²)的暴力搜索;最终通过数值积分更新粒子位置和速度,并处理与刚体、边界的碰撞交互,实现逼真的流体行为如液体流动、飞溅、表面波动等效果。
虽然我很想向你们展示具体的源码,但是说实话,我自己都还没有看懂,且这个源码的篇幅是巨大的,所以我们先不贴在这里了。
还有很多粒子系统实现的具体物体,我们就不一一展示了。(其实是这一块我也没有太学懂)
扩展功能层
角色控制器 (Character Controller)
实现内容:实现了专门用于游戏角色移动的高级控制系统,提供"碰撞并滑动"算法来处理角色与环境的交互。实现方式:通过创建内部运动学刚体作为代理,使用扫描测试(sweep test)检测移动路径上的碰撞,然后将复杂的3D移动分解为上、侧、下三个独立的运动分量进行处理,每个分量都有独立的迭代求解过程。技术特点:采用体积缓存技术提高性能,支持障碍物上下文系统,内置重叠恢复机制防止角色卡在几何体内,提供斜坡限制和台阶攀爬功能。
载具系统 (Vehicle System)
实现内容:实现了完整的轮式载具物理模拟系统,包括引擎、传动系统、悬挂、轮胎等所有载具组件的真实物理行为。实现方式:采用组件化架构设计,通过PxVehicleComponent基类构建模块化的更新管线,每个组件负责特定的物理计算(如引擎扭矩、轮胎力、悬挂力等),最终通过约束求解器将所有力整合到载具刚体上。技术特点:支持多种驱动类型(四轮驱动、多轮驱动、坦克驱动),实现了复杂的轮胎摩擦模型和阿克曼转向几何,提供自动变速箱和差速器模拟,与PhysX场景查询系统深度集成进行地面检测。
烹饪系统 (Cooking)
实现内容:实现了将原始几何数据转换为PhysX运行时优化格式的预处理系统,生成用于高效碰撞检测的数据结构。实现方式:通过多阶段处理管线,首先进行网格清理和验证,然后根据不同几何类型选择相应算法(如三角网格使用BVH构建、凸包使用Quickhull算法、四面体网格进行FEM预处理),最后序列化为二进制格式供运行时加载。技术特点:支持多种空间划分结构(BVH33、BVH34),提供网格简化和拓扑优化,能够生成GPU加速数据,支持SDF生成用于高级碰撞检测,具备完整的错误检查和性能调优参数。
可视化调试 (PVD)
实现内容:实现了实时物理世界可视化和调试系统,提供物理对象状态监控、性能分析和远程调试功能。实现方式:通过数据流抽象层(PvdDataStream)收集物理引擎内部状态,使用元数据系统描述对象属性和关系,通过网络传输协议将数据发送到独立的可视化应用程序,支持实时更新和历史回放。技术特点:采用分层架构设计分离数据收集和传输,支持多种传输方式(TCP、文件),提供细粒度的调试标志控制,具备内存和性能监控能力,支持自定义渲染和用户交互,能够处理大规模场景的实时可视化。
东西太多了,暂时讲不完,先放在这吧。