1. 从“撞上”到“检测”:三维碰撞检测的工程意义
在三维虚拟世界里,一个角色能否走上台阶,一颗子弹能否击中目标,一辆赛车是否会撞上护栏,这些看似简单的交互背后,都依赖着一套复杂而精密的计算系统——三维碰撞检测。这不仅仅是游戏开发的核心,更是工业仿真、虚拟现实、机器人路径规划等众多领域的基础设施。很多刚接触三维开发的朋友,可能会觉得碰撞检测就是“两个物体碰上了”,但实际工程中,这远非一个简单的布尔判断。它涉及到空间划分、几何计算、性能优化等一系列问题,处理不好,轻则角色“穿墙”,重则程序卡顿崩溃。
今天要聊的Bullet Physics,就是解决这些问题的行业标杆之一。它是一个开源的、跨平台的物理模拟库,以其稳定、高效和功能全面而闻名,被广泛应用于游戏(如《侠盗猎车手V》、《荒野大镖客:救赎2》的载具物理)、电影特效(如《阿凡达》的特效模拟)以及各类仿真软件中。对于开发者而言,Bullet 提供了一个强大的工具箱,让我们不必从零开始推导复杂的物理公式,就能实现逼真的刚体动力学、柔体、布料乃至车辆模拟。而这一切的起点,就是碰撞检测。
本文将从一个实践者的角度,带你快速上手 Bullet,并深入其碰撞检测的核心机制。我们会从最基础的场景搭建开始,逐步拆解碰撞形状、世界管理、检测流程以及结果处理,并分享在实际项目中容易踩到的“坑”和优化技巧。无论你是游戏开发者、仿真工程师,还是对物理模拟感兴趣的技术爱好者,这篇文章都将为你提供一个扎实的起点。
2. 环境搭建与第一个碰撞世界
在开始写代码之前,我们需要先把 Bullet 库“请”到我们的项目中。Bullet 的官方维护非常活跃,获取方式也很灵活。
2.1 获取与编译 Bullet 库
最推荐的方式是从其 GitHub 仓库获取源码并自行编译。这样做的好处是,你可以根据目标平台(Windows, Linux, macOS)和编译环境(MSVC, GCC, Clang)进行最优化配置,并且能方便地开启或关闭某些模块(如柔体、多线程支持)。
git clone https://github.com/bulletphysics/bullet3.git cd bullet3Bullet 使用 CMake 作为构建系统。假设我们在 Linux/macOS 下,创建一个构建目录并配置:
mkdir build cd build cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON -DBUILD_BULLET2_DEMOS=OFF -DBUILD_EXTRAS=OFF make -j4 sudo make install这里有几个关键参数:
-DCMAKE_BUILD_TYPE=Release:生成优化后的发布版本,性能更好。-DBUILD_SHARED_LIBS=ON:生成动态链接库(.so 或 .dll),方便多个项目共用。如果希望静态链接,则设为OFF。-DBUILD_BULLET2_DEMOS=OFF:关闭官方示例的编译,可以加快编译速度,初学时可先关闭。-DBUILD_EXTRAS=OFF:关闭一些额外工具和示例,同样为了简化。
在 Windows 下,你可以使用 CMake GUI 工具生成 Visual Studio 解决方案文件(.sln),然后用 VS 打开并编译。
编译安装完成后,你会在系统库路径(如/usr/local/lib)找到libBulletCollision.so,libBulletDynamics.so等库文件,头文件通常在/usr/local/include/bullet。在你的项目中,需要正确链接这些库并包含头文件。
注意:如果你使用包管理器(如 Ubuntu 的
apt或 macOS 的Homebrew),也可以直接安装预编译的包(如apt install libbullet-dev),但版本可能不是最新的,且定制化选项较少。对于生产环境,建议从源码编译以控制版本和特性。
2.2 初始化物理世界:一切的舞台
物理世界(btDiscreteDynamicsWorld)是 Bullet 中所有物理实体(刚体)活动的舞台。它负责管理这些实体,并逐步推进时间,模拟物理过程。创建一个最简单的世界,通常需要以下几个步骤:
#include <btBulletDynamicsCommon.h> int main() { // 1. 碰撞配置(Collision Configuration) // 它管理着碰撞算法的内存池和默认设置。对于大多数应用,使用默认配置即可。 btDefaultCollisionConfiguration* collisionConfiguration = new btDefaultCollisionConfiguration(); // 2. 碰撞调度器(Collision Dispatcher) // 它负责在碰撞对(两个碰撞形状)之间分配合适的碰撞检测算法。 // 例如,一个盒子和一个球体碰撞,它会自动选择 box-sphere 算法。 btCollisionDispatcher* dispatcher = new btCollisionDispatcher(collisionConfiguration); // 3. 广相碰撞检测(Broadphase) // 这是性能优化的关键。它快速筛选出“可能”发生碰撞的物体对,避免对所有物体两两进行精确计算(窄相检测)。 // btDbvtBroadphase 使用动态包围体层次树(Dynamic Bounding Volume Hierarchy),是通用且高效的选择。 btBroadphaseInterface* overlappingPairCache = new btDbvtBroadphase(); // 4. 约束求解器(Constraint Solver) // 负责计算力、冲量,并求解约束(如关节、接触点),以更新物体的位置和速度。 btSequentialImpulseConstraintSolver* solver = new btSequentialImpulseConstraintSolver; // 5. 动力学世界(Dynamics World) // 将上述所有组件组合起来,形成我们的物理世界。 btDiscreteDynamicsWorld* dynamicsWorld = new btDiscreteDynamicsWorld( dispatcher, overlappingPairCache, solver, collisionConfiguration ); // 设置重力加速度(单位:米/秒²)。Y轴向上,负值表示向下。 dynamicsWorld->setGravity(btVector3(0, -9.8, 0)); // ... 在这里添加物体(刚体) // 模拟循环(通常放在游戏主循环中) for (int i = 0; i < 300; ++i) { // 模拟300步 dynamicsWorld->stepSimulation(1.f / 60.f, 10); // 每步模拟1/60秒,最多10次子步 } // 程序结束时,按创建顺序的逆序清理资源 delete dynamicsWorld; delete solver; delete overlappingPairCache; delete dispatcher; delete collisionConfiguration; return 0; }这段代码搭建了一个完整的物理模拟框架。stepSimulation函数是核心,它驱动时间前进。其第一个参数是时间步长(deltaTime),通常与你的游戏帧时间一致(如1/60秒)。第二个参数是最大子步数,Bullet 内部会将大的时间步拆分成更小的子步来保证模拟的稳定性,尤其是在高速运动或复杂碰撞时。
3. 碰撞形状:定义物体的“物理边界”
在 Bullet 中,一个物体(刚体)的视觉模型(渲染用的三角网格)和它的物理边界是分离的。我们用一个更简单的几何形状(碰撞形状,btCollisionShape)来近似表示这个物体的物理轮廓,用于碰撞检测计算。这样做极大地提升了性能。
3.1 基本碰撞形状
Bullet 提供了多种基本形状,适用于不同的物体:
盒子(
btBoxShape):由半边长(half extents)定义。例如,一个长宽高分别为2、1、1.5米的盒子,其半边长向量为btVector3(1.0, 0.5, 0.75)。btCollisionShape* boxShape = new btBoxShape(btVector3(1.0f, 0.5f, 0.75f));球体(
btSphereShape):只需一个半径参数。btCollisionShape* sphereShape = new btSphereShape(1.0f);胶囊体(
btCapsuleShape):由半径和高度(不含两端的半球)定义。非常适合用于角色控制器,因为它能平滑地滑过台阶和斜坡。btCollisionShape* capsuleShape = new btCapsuleShape(0.5f, 2.0f); // 半径0.5米,柱体高2米圆柱体(
btCylinderShape):类似盒子,由半边长定义,但碰撞检测按圆柱处理。注意其朝向(默认Y轴为高)。圆锥体(
btConeShape)、多面体(btConvexHullShape)等。
3.2 复合碰撞形状与三角网格
对于复杂物体,基本形状可能无法准确匹配。这时有两种主要选择:
复合形状(
btCompoundShape):将多个基本形状组合在一起。例如,一把椅子可以由一个盒子的座面、四个圆柱体的腿组合而成。这比使用一个复杂的单一形状效率更高,且更贴合实际。btCompoundShape* chairShape = new btCompoundShape(); btTransform localTrans; localTrans.setIdentity(); // 添加座面(盒子) localTrans.setOrigin(btVector3(0, 0.5, 0)); // 座面在本地坐标中的位置 chairShape->addChildShape(localTrans, new btBoxShape(btVector3(0.4, 0.05, 0.4))); // 添加一条腿(圆柱) localTrans.setOrigin(btVector3(0.3, 0, 0.3)); chairShape->addChildShape(localTrans, new btCylinderShape(btVector3(0.03, 0.5, 0.03))); // ... 添加其他三条腿三角网格形状(
btBvhTriangleMeshShape):用于完全静态的、细节复杂的场景,如地形、建筑内部。它使用一个三角网格来精确表示碰撞表面。关键点:btBvhTriangleMeshShape是不可移动的(静态或运动学刚体),且只能与凸形状(如球、盒、胶囊)进行碰撞检测,不能与其他三角网格形状检测(除非进行特殊配置,但性能很差)。btTriangleMesh* mesh = new btTriangleMesh(); // 假设你有顶点数组 vertices 和索引数组 indices for (int i = 0; i < numTriangles; ++i) { int idx0 = indices[i*3]; int idx1 = indices[i*3+1]; int idx2 = indices[i*3+2]; mesh->addTriangle( btVector3(vertices[idx0].x, vertices[idx0].y, vertices[idx0].z), btVector3(vertices[idx1].x, vertices[idx1].y, vertices[idx1].z), btVector3(vertices[idx2].x, vertices[idx2].y, vertices[idx2].z) ); } btBvhTriangleMeshShape* terrainShape = new btBvhTriangleMeshShape(mesh, true); // true 表示使用量化AABB树,优化查询
实操心得:形状选型原则
- 能用简单,不用复杂:优先使用基本形状或其组合。一个胶囊体模拟的角色,比用上千个三角面模拟的角色,在碰撞检测上快几个数量级。
- 动静分离:静态且复杂的场景(地形、房屋)用
btBvhTriangleMeshShape。动态物体用基本形状或复合形状。- 匹配视觉轮廓:碰撞形状应尽量贴近视觉模型的轮廓,尤其是玩家交互频繁的部位,否则会产生“打不中”或“隔空碰撞”的糟糕体验。对于玩家角色,胶囊体是黄金标准。
4. 创建刚体:赋予形状质量与运动
有了碰撞形状,我们还需要将其包装成刚体(btRigidBody),并为其设置质量、惯性、位置等属性,它才能真正参与物理模拟。
4.1 刚体创建流程
// 1. 定义物体的初始变换(位置和朝向) btTransform startTransform; startTransform.setIdentity(); startTransform.setOrigin(btVector3(0, 10, 0)); // 初始位置在 (0, 10, 0) // 2. 创建运动状态(Motion State) // 它负责同步物理引擎中刚体的变换与你渲染引擎中对应模型的变换。 // 这是一个可选但强烈推荐的组件,它解耦了物理和渲染。 btDefaultMotionState* myMotionState = new btDefaultMotionState(startTransform); // 3. 计算物体的局部惯性(Local Inertia) // 惯性决定了物体旋转的难易程度,它依赖于物体的质量和碰撞形状。 btVector3 localInertia(0, 0, 0); float mass = 1.0f; // 质量,单位为千克 if (mass != 0.f) { // 只有非零质量的动态物体才需要计算惯性 collisionShape->calculateLocalInertia(mass, localInertia); } // 4. 创建刚体构造信息 btRigidBody::btRigidBodyConstructionInfo rbInfo(mass, myMotionState, collisionShape, localInertia); // 5. 创建刚体 btRigidBody* body = new btRigidBody(rbInfo); // 6. (可选)设置附加属性 body->setRestitution(0.5f); // 恢复系数(弹性),0.0为完全非弹性,1.0为完全弹性 body->setFriction(0.8f); // 摩擦系数 // 7. 将刚体添加到物理世界 dynamicsWorld->addRigidBody(body);4.2 刚体类型:静态、动态与运动学
通过设置质量(mass),我们可以定义刚体的类型:
- 静态刚体(Static):质量为零(
mass = 0)。它永远不会移动,比如地面、墙壁。物理引擎会对其进行特殊优化。 - 动态刚体(Dynamic):质量大于零。它会受到重力、力、碰撞的影响而运动。大部分可交互的物体都是动态的。
- 运动学刚体(Kinematic):质量为零,但你可以通过代码直接设置其位置和速度。它不会受力的影响,但可以推动动态刚体。常用于电梯、移动平台,或者由动画驱动的角色(此时通常配合胶囊体形状)。
// 创建一个运动学刚体(例如,一个移动平台) btRigidBody::btRigidBodyConstructionInfo rbInfo(0, myMotionState, collisionShape, btVector3(0,0,0)); btRigidBody* kinematicBody = new btRigidBody(rbInfo); kinematicBody->setCollisionFlags(kinematicBody->getCollisionFlags() | btCollisionObject::CF_KINEMATIC_OBJECT); kinematicBody->setActivationState(DISABLE_DEACTIVATION); // 防止休眠 // 在游戏循环中,你可以手动更新它的位置 btTransform trans; kinematicBody->getMotionState()->getWorldTransform(trans); trans.setOrigin(newPosition); kinematicBody->getMotionState()->setWorldTransform(trans); kinematicBody->setWorldTransform(trans); // 同时更新物理世界的变换5. 碰撞检测结果获取与处理
创建了世界和物体,并让它们运动起来后,我们如何知道两个物体发生了碰撞,并获取碰撞的详细信息(如碰撞点、法线)呢?Bullet 提供了几种方式。
5.1 接触点检测(Contact Point Processing)
最常用的是通过物理世界获取每帧的接触点信息。在stepSimulation之后,我们可以遍历所有的“接触流形”(Contact Manifold),它包含了两个碰撞对象之间在当前帧的所有接触点。
dynamicsWorld->stepSimulation(deltaTime, 10); // 获取碰撞世界的调度器 btDispatcher* dispatcher = dynamicsWorld->getDispatcher(); int numManifolds = dispatcher->getNumManifolds(); for (int i = 0; i < numManifolds; i++) { btPersistentManifold* contactManifold = dispatcher->getManifoldByIndexInternal(i); // 获取发生碰撞的两个物体 const btCollisionObject* obA = static_cast<const btCollisionObject*>(contactManifold->getBody0()); const btCollisionObject* obB = static_cast<const btCollisionObject*>(contactManifold->getBody1()); // 获取用户指针(通常用于关联你的游戏对象) MyGameObject* objA = static_cast<MyGameObject*>(obA->getUserPointer()); MyGameObject* objB = static_cast<MyGameObject*>(obB->getUserPointer()); int numContacts = contactManifold->getNumContacts(); for (int j = 0; j < numContacts; j++) { btManifoldPoint& pt = contactManifold->getContactPoint(j); // 只处理“新的”或“持续的”接触点,忽略已解决的 if (pt.getDistance() < 0.f) { btVector3 ptA = pt.getPositionWorldOnA(); // 在物体A上的碰撞点(世界坐标) btVector3 ptB = pt.getPositionWorldOnB(); // 在物体B上的碰撞点 btVector3 normalOnB = pt.m_normalWorldOnB; // 从B指向A的碰撞法线(单位向量) float impulse = pt.getAppliedImpulse(); // 应用于此接触点的冲量大小 // 在这里处理你的游戏逻辑:播放声音、造成伤害、触发事件等 if (objA && objB) { objA->onCollision(objB, ptA, normalOnB, impulse); objB->onCollision(objA, ptB, -normalOnB, impulse); // 法线方向相反 } } } }5.2 光线投射(Raycasting)
光线投射用于查询从一点沿某一方向,第一个被击中的物体是什么。这在实现鼠标拾取、武器瞄准、视线检测等功能时非常有用。
btVector3 rayFrom(0, 10, 0); // 射线起点 btVector3 rayTo(0, -10, 0); // 射线终点 btCollisionWorld::ClosestRayResultCallback rayCallback(rayFrom, rayTo); dynamicsWorld->rayTest(rayFrom, rayTo, rayCallback); if (rayCallback.hasHit()) { // 击中了某个物体 btVector3 hitPoint = rayCallback.m_hitPointWorld; // 击中点 btVector3 hitNormal = rayCallback.m_hitNormalWorld; // 击中点法线 const btCollisionObject* hitObject = rayCallback.m_collisionObject; // 被击中的物体 MyGameObject* hitGameObj = static_cast<MyGameObject*>(hitObject->getUserPointer()); // ... 处理击中逻辑 }5.3 形状投射(Sweep Test)
形状投射比光线投射更强大,它允许你用一个形状(如胶囊体)沿着一条路径进行扫描,检测是否会与场景中的物体碰撞。这是实现角色控制器移动、子弹体积检测的基石。
// 假设我们用一个胶囊体形状进行扫描 btTransform startTrans, endTrans; startTrans.setIdentity(); startTrans.setOrigin(btVector3(0, 5, 0)); endTrans.setIdentity(); endTrans.setOrigin(btVector3(10, 5, 0)); btCollisionWorld::ClosestConvexResultCallback sweepCallback(btVector3(0,0,0), btVector3(0,0,0)); sweepCallback.m_collisionFilterGroup = COL_PLAYER; // 碰撞过滤组 sweepCallback.m_collisionFilterMask = COL_GROUND | COL_ENEMY; // 碰撞过滤掩码 dynamicsWorld->convexSweepTest(capsuleShape, startTrans, endTrans, sweepCallback); if (sweepCallback.hasHit()) { float hitFraction = sweepCallback.m_closestHitFraction; // 在路径上的碰撞比例 (0~1) btVector3 hitPoint = sweepCallback.m_hitPointWorld; // 根据 hitFraction,你可以计算出安全的移动终点 btVector3 safeEnd = startTrans.getOrigin() + (endTrans.getOrigin() - startTrans.getOrigin()) * hitFraction; }6. 性能优化与常见陷阱
直接使用 Bullet 而不加优化,在物体数量稍多时(几百个)就可能遇到性能瓶颈。以下是一些关键的优化策略和避坑指南。
6.1 碰撞过滤(Collision Filtering)
不是所有物体都需要相互检测碰撞。比如,子弹不需要和子弹碰撞,特效粒子不需要和任何物体碰撞。Bullet 使用组(Group)和掩码(Mask)来实现过滤。
每个刚体在添加到世界时可以指定其所属的碰撞组和碰撞掩码:
- 碰撞组(
group):一个整数,通常用2的幂次方(1,2,4,8...)来定义不同的类别,如COL_GROUND=1,COL_PLAYER=2,COL_ENEMY=4,COL_BULLET=8。 - 碰撞掩码(
mask):一个整数,其二进制位表示该物体会与哪些组的物体进行碰撞检测。
// 定义过滤组 enum CollisionGroups { COL_NOTHING = 0, COL_GROUND = 1 << 0, // 1 COL_PLAYER = 1 << 1, // 2 COL_ENEMY = 1 << 2, // 4 COL_BULLET = 1 << 3, // 8 COL_TRIGGER = 1 << 4 // 16 }; // 地面:只和玩家、敌人碰撞 short groundGroup = COL_GROUND; short groundMask = COL_PLAYER | COL_ENEMY; dynamicsWorld->addRigidBody(groundBody, groundGroup, groundMask); // 玩家:和地面、敌人、触发器碰撞 short playerGroup = COL_PLAYER; short playerMask = COL_GROUND | COL_ENEMY | COL_TRIGGER; dynamicsWorld->addRigidBody(playerBody, playerGroup, playerMask); // 子弹:只和敌人碰撞 short bulletGroup = COL_BULLET; short bulletMask = COL_ENEMY; dynamicsWorld->addRigidBody(bulletBody, bulletGroup, bulletMask);6.2 物体休眠(Activation State)
动态刚体在静止不动一段时间后,Bullet 会将其置为“休眠”(ISLAND_SLEEPING)状态。休眠的物体将跳过大部分物理计算,直到被外力(碰撞、施加力等)唤醒。这是非常重要的性能优化。
常见陷阱:
- 运动学刚体不休眠:如果你创建了一个运动学刚体(如移动平台),并希望它持续运动,需要显式设置
setActivationState(DISABLE_DEACTIVATION),否则它可能会在不该休眠的时候休眠,导致物理模拟出错。 - 手动唤醒:如果你通过代码直接修改了一个动态刚体的位置(
setWorldTransform),它不会自动唤醒。你需要调用activate()来强制唤醒它,否则它可能“卡”在原地。 - 强制激活:对于玩家角色等关键物体,可以设置
setActivationState(DISABLE_DEACTIVATION)防止其休眠,但会带来额外的性能开销,需权衡。
6.3 使用合适的碰撞形状与层次结构
- 避免使用过于复杂的凸包形状:
btConvexHullShape虽然能包裹任意凸体,但顶点数过多会严重影响性能。尽量用基本形状组合。 - 对于大量静态小物体:可以考虑使用
btCompoundShape将它们合并成一个大的静态物体,减少世界中的刚体数量。 - 动态 AABB 树更新开销:
btDbvtBroadphase虽然高效,但当大量动态物体每帧都在移动时,更新其包围盒树也有开销。尽量减少不必要的物体运动。
6.4 调试渲染与可视化
Bullet 自带一个简单的调试绘制器(btIDebugDraw),可以将碰撞形状、接触点、法线等以线框形式绘制出来。这对于验证碰撞形状是否正确、检测是否生效至关重要。
你需要实现btIDebugDraw接口,将其与你图形 API(OpenGL/DirectX)的绘制命令关联起来,然后在物理世界中设置调试绘制器并开启调试模式。
class MyDebugDrawer : public btIDebugDraw { // 实现 drawLine, drawContactPoint, reportErrorWarning 等方法... }; MyDebugDrawer debugDrawer; dynamicsWorld->setDebugDrawer(&debugDrawer); // 在渲染循环中 dynamicsWorld->debugDrawWorld();开启调试后,你会在场景中看到所有碰撞形状的轮廓线,发生碰撞时会有接触点和法线显示,这是排查碰撞问题最直观的工具。
7. 从检测到响应:构建一个简单的碰撞交互
让我们把上面的知识串联起来,实现一个经典场景:一个球体从空中落下,撞击一个盒子,然后根据碰撞法线反弹。
// 初始化世界、形状等(略)... // 创建静态地面(一个巨大的盒子) btBoxShape* groundShape = new btBoxShape(btVector3(50, 1, 50)); btTransform groundTransform; groundTransform.setIdentity(); groundTransform.setOrigin(btVector3(0, -1, 0)); btDefaultMotionState* groundMotionState = new btDefaultMotionState(groundTransform); btRigidBody::btRigidBodyConstructionInfo groundRBCI(0, groundMotionState, groundShape, btVector3(0,0,0)); btRigidBody* groundBody = new btRigidBody(groundRBCI); dynamicsWorld->addRigidBody(groundBody); // 创建动态球体 btSphereShape* sphereShape = new btSphereShape(1.0); btTransform sphereTransform; sphereTransform.setIdentity(); sphereTransform.setOrigin(btVector3(0, 10, 0)); btDefaultMotionState* sphereMotionState = new btDefaultMotionState(sphereTransform); btVector3 sphereInertia(0,0,0); float sphereMass = 1.0f; sphereShape->calculateLocalInertia(sphereMass, sphereInertia); btRigidBody::btRigidBodyConstructionInfo sphereRBCI(sphereMass, sphereMotionState, sphereShape, sphereInertia); btRigidBody* sphereBody = new btRigidBody(sphereRBCI); sphereBody->setRestitution(0.8f); // 设置较高的弹性 dynamicsWorld->addRigidBody(sphereBody); // 创建动态盒子 btBoxShape* boxShape = new btBoxShape(btVector3(2, 0.5, 2)); btTransform boxTransform; boxTransform.setIdentity(); boxTransform.setOrigin(btVector3(5, 2, 0)); btDefaultMotionState* boxMotionState = new btDefaultMotionState(boxTransform); btVector3 boxInertia(0,0,0); float boxMass = 5.0f; // 盒子比球重 boxShape->calculateLocalInertia(boxMass, boxInertia); btRigidBody::btRigidBodyConstructionInfo boxRBCI(boxMass, boxMotionState, boxShape, boxInertia); btRigidBody* boxBody = new btRigidBody(boxRBCI); boxBody->setRestitution(0.3f); dynamicsWorld->addRigidBody(boxBody); // 模拟循环 for (int i = 0; i < 600; ++i) { dynamicsWorld->stepSimulation(1.f / 60.f, 10); // 获取并更新球体的渲染位置 btTransform sphereCurrentTrans; sphereBody->getMotionState()->getWorldTransform(sphereCurrentTrans); // 将 sphereCurrentTrans 传递给你的渲染引擎... // 检测球与盒子的碰撞(仅作示例,实际应在接触点处理回调中做) // 这里使用手动检测来演示原理 int numManifolds = dynamicsWorld->getDispatcher()->getNumManifolds(); for (int m = 0; m < numManifolds; m++) { btPersistentManifold* manifold = dynamicsWorld->getDispatcher()->getManifoldByIndexInternal(m); const btCollisionObject* obA = manifold->getBody0(); const btCollisionObject* obB = manifold->getBody1(); if ((obA == sphereBody && obB == boxBody) || (obA == boxBody && obB == sphereBody)) { if (manifold->getNumContacts() > 0) { std::cout << "Sphere hit Box at frame: " << i << std::endl; // 可以在这里播放碰撞音效、产生粒子等 } } } }在这个例子中,球体因为设置了较高的恢复系数(0.8),撞击地面和盒子后会有明显的反弹。而较重的盒子被球撞击后,只会轻微移动。通过调整质量、摩擦力和恢复系数,你可以模拟出各种不同的物理材质感觉,如橡胶球、木箱、钢铁等。
8. 进阶话题与资源指引
入门之后,你可能还会遇到更复杂的需求。这里提供一些方向:
- 触发器(Trigger Volumes):有时我们只需要知道一个物体进入了某个区域,而不需要物理碰撞响应(如拾取物品、进入下一关)。可以将刚体的碰撞标志设置为
CF_NO_CONTACT_RESPONSE,然后通过btGhostObject来检测重叠事件。 - 连续碰撞检测(CCD):对于高速运动的物体(如子弹),可能会在单帧时间内“穿过”薄墙。Bullet 提供了 CCD 机制,通过形状扫描来防止这类“隧道效应”。
- 约束与关节(Constraints/Joints):用于连接两个刚体,实现铰链、滑块、球窝、车辆悬挂等复杂运动。
btHingeConstraint,btSliderConstraint,btGeneric6DofConstraint等是常用的关节类型。 - 柔体与布料(Soft Body & Cloth):Bullet 也包含柔体模拟模块,可以模拟旗帜、布料、绳索等可变形物体。
- 多线程:Bullet 支持多线程模拟(
btParallelConstraintSolver),可以充分利用多核 CPU 提升复杂场景的性能。
学习资源:
- 官方资源:Bullet 的 GitHub Wiki 和源码中的
Demos文件夹是最好的学习材料。 - 调试与可视化:花时间搭建好调试绘制,它能帮你直观理解碰撞形状和物理过程,事半功倍。
- 社区:Bullet 的官方论坛和 Stack Overflow 上有大量历史问题和解答。
从我个人的项目经验来看,掌握 Bullet 的关键在于理解其“组件化”的设计思想:世界、形状、刚体、约束各司其职。初期最常见的错误是混淆了视觉坐标与物理坐标、忽略了单位一致性(建议全程使用米-千克-秒制),以及没有正确设置碰撞过滤导致性能低下或逻辑错误。多动手写小 Demo 测试每个功能点,善用调试绘制,你会很快建立起三维物理世界的直觉。