
1. 为什么要在项目里引入FCL做碰撞检测做过三维仿真、机器人路径规划或者游戏物理模块的兄弟应该都有体会模型能不能“撞上”直接决定了整个系统的可信度。我最早做机械臂抓取仿真的时候用的是自己写的AABB包围盒判断简单粗暴两个盒子重叠就算碰撞。结果机械臂末端离工件还有两厘米程序就报碰撞抓取动作直接卡死。后来换成基于三角面片的精确检测又慢得离谱一个简单的场景跑一次检测要几百毫秒实时性完全没法看。FCLFlexible Collision Library就是在这个背景下进入我视野的。它是一套用C写的开源碰撞检测库专门处理三维几何体之间的碰撞、距离查询和连续碰撞检测。你可以把它理解成一个“碰撞检测的瑞士军刀”底层帮你封装好了BVH树、包围盒层次结构、各种几何图元的相交测试你只需要把模型丢进去调几个API就能拿到结果。它支持球体、盒子、圆柱、圆锥、胶囊体这些基本图元也支持任意三角网格模型还能做连续碰撞检测防止高速物体“穿模”。这篇文章适合谁看如果你正在做机器人仿真、游戏物理、三维路径规划或者单纯想给自己的C项目加一个靠谱的碰撞检测模块那这篇内容就是给你准备的。我会从环境搭建开始一步步带你跑通一个完整的FCL碰撞检测示例把STL模型加载进去输出碰撞结果。代码可以直接抄参数我会解释清楚为什么这么设。整个过程顺利的话五分钟就能看到效果但背后的坑我会提前给你标出来。提示FCL本身不负责模型加载和渲染它只做几何计算。你需要自己用Assimp或者其他库把STL文件读成三角面片再喂给FCL。2. 环境准备与依赖库选型2.1 编译工具链和第三方库清单FCL的编译依赖不算复杂但版本匹配比较讲究。我实测下来在Ubuntu 20.04和Windows 10/11的VS2019、VS2022上都能跑通。下面是我常用的组合组件推荐版本说明CMake3.16以上构建工具FCL用CMake管理Eigen3.3.7以上矩阵运算FCL的核心依赖libccd2.0以上凸体碰撞检测库FCL的底层依赖FCL0.6.1或0.7.0主库建议用0.7.0Assimp5.0以上加载STL/OBJ等模型文件C标准C11及以上FCL大量使用智能指针和lambdaWindows下如果不想自己编译可以用vcpkg一键安装vcpkg install fcl assimp eigen3。但vcpkg的FCL版本可能偏旧我建议还是源码编译可控性强。Ubuntu下直接apt install libfcl-dev libassimp-dev libeigen3-dev也能用但同样存在版本问题。注意FCL 0.7.0和0.6.1的API有细微差别比如CollisionRequest的构造函数参数顺序变了。网上很多老教程用的是0.5.x直接抄会编译报错。建议先确认自己装的版本。2.2 源码编译FCL的完整步骤我以Ubuntu 20.04为例把编译过程拆开讲。Windows下用CMake GUI操作逻辑一样只是路径换成反斜杠。第一步装Eigen。Eigen是纯头文件库解压后把Eigen文件夹拷到/usr/local/include就行或者用cmake --install指定路径。第二步编译libccd。下载源码后mkdir build cd build cmake .. -DCMAKE_INSTALL_PREFIX/usr/local make -j4 sudo make install第三步编译FCL。这里有个关键点FCL默认会去找系统里的Eigen和libccd如果找不到就报错。你可以用-DEIGEN3_INCLUDE_DIR和-DCCD_INCLUDE_DIR手动指定。git clone https://github.com/flexible-collision-library/fcl.git cd fcl mkdir build cd build cmake .. -DCMAKE_INSTALL_PREFIX/usr/local \ -DFCL_BUILD_TESTSOFF \ -DFCL_BUILD_EXAMPLESOFF make -j4 sudo make installFCL_BUILD_TESTS和FCL_BUILD_EXAMPLES关掉能省不少编译时间除非你想跑官方测试用例。编译完成后/usr/local/lib下会有libfcl.so头文件在/usr/local/include/fcl。第四步装Assimp。这个库用来读STLUbuntu下sudo apt install libassimp-dev即可。Windows下用vcpkg或者源码编译都行。2.3 项目CMakeLists的写法很多新手卡在链接阶段报一堆undefined reference。核心原因是FCL依赖Eigen和libccd你的项目也得显式链接它们。下面是我常用的CMakeLists模板cmake_minimum_required(VERSION 3.16) project(fcl_demo) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(fcl REQUIRED) find_package(assimp REQUIRED) find_package(Eigen3 REQUIRED) include_directories(${FCL_INCLUDE_DIRS} ${ASSIMP_INCLUDE_DIRS} ${EIGEN3_INCLUDE_DIR}) add_executable(fcl_demo main.cpp) target_link_libraries(fcl_demo ${FCL_LIBRARIES} ${ASSIMP_LIBRARIES})如果find_package(fcl)找不到就手动指定FCL_INCLUDE_DIRS和FCL_LIBRARIES的路径。Windows下链接的是fcl.lib和ccd.lib别忘了把dll拷到exe旁边。实操心得我习惯在CMake里加-DCMAKE_BUILD_TYPEReleaseDebug模式下FCL的BVH构建会慢好几倍测性能的时候一定要用Release。3. 核心概念拆解FCL到底怎么判断碰撞3.1 包围盒层次结构与BVH树FCL判断两个三角网格是否碰撞不是傻乎乎地拿几万个三角形两两测试。它先用包围盒层次结构Bounding Volume HierarchyBVH把模型组织成一棵树。每个节点是一个包围盒根节点包住整个模型子节点包住局部区域叶子节点包住单个三角形。检测的时候先比根节点的包围盒。如果不相交直接返回“无碰撞”几万个三角形一次都不用测。如果相交就递归往下查直到叶子节点。这个思路就像查字典先看目录定位到字母再翻到具体页码而不是从第一页开始逐字找。FCL支持几种包围盒类型AABB轴对齐包围盒、OBB有向包围盒、RSS矩形 swept sphere、kIOSk-DOP。默认用OBB因为它在紧密性和计算速度之间平衡得最好。你可以在CollisionRequest里通过gjk_solver_type和bv_hierarchy_type调整但大多数场景默认值就够用。3.2 碰撞请求与碰撞结果的参数含义FCL的API设计很直观构造一个CollisionRequest调collide()拿CollisionResult。但里面的参数不搞清楚结果可能不是你想要的。CollisionRequest request; request.num_max_contacts 10; // 最多返回多少个接触点 request.enable_contact true; // 是否计算接触点位置 request.gjk_solver_type GST_LIBCCD; // 用libccd做凸体检测num_max_contacts很关键。如果你只关心“撞没撞”设成1就行省时间。如果要算碰撞力或者做碰撞响应就得设大一点拿到足够的接触点。enable_contact打开后CollisionResult里会有contacts数组每个元素包含位置、法向量和穿透深度。gjk_solver_type有三个选项GST_LIBCCD、GST_INDEP、GST_NONE。GST_LIBCCD最稳支持凸体精确检测GST_INDEP是FCL自带的独立实现精度稍差但依赖少GST_NONE只做包围盒检测最快但最粗糙。我一般用GST_LIBCCD。3.3 连续碰撞检测与离散碰撞检测的区别离散碰撞检测是“拍快照”在每个时间步检查两个物体是否重叠。如果物体速度很快这一帧在左边下一帧在右边中间穿过去了离散检测就漏了。连续碰撞检测CCD则是把时间轴考虑进去检测两个物体在运动过程中是否相交。FCL的CCD通过ContinuousCollisionRequest实现需要指定运动前后的变换矩阵。它会计算一个TOITime of Impact也就是第一次发生碰撞的时间点。做高速子弹、快速旋转的机械臂仿真时CCD是必须的。但CCD计算量比离散检测大不少如果物体速度不快用离散就够了。注意CCD对三角网格的支持有限复杂网格建议先用凸包简化再做连续检测。4. 完整代码实现从STL加载到碰撞输出4.1 用Assimp读取STL并转成FCL三角网格Assimp读STL很简单但读出来的数据是Assimp自己的格式得转成FCL的BVHModel。下面是我封装的一个函数直接拿去用#include assimp/Importer.hpp #include assimp/scene.h #include assimp/postprocess.h #include fcl/fcl.h #include vector std::shared_ptrfcl::BVHModelfcl::OBBRSS loadSTL(const std::string path) { Assimp::Importer importer; const aiScene* scene importer.ReadFile(path, aiProcess_Triangulate | aiProcess_JoinIdenticalVertices); if (!scene || scene-mNumMeshes 0) { throw std::runtime_error(Failed to load STL: path); } auto model std::make_sharedfcl::BVHModelfcl::OBBRSS(); model-beginModel(); for (unsigned int m 0; m scene-mNumMeshes; m) { aiMesh* mesh scene-mMeshes[m]; for (unsigned int i 0; i mesh-mNumFaces; i) { aiFace face mesh-mFaces[i]; if (face.mNumIndices ! 3) continue; fcl::Vector3d v0( mesh-mVertices[face.mIndices[0]].x, mesh-mVertices[face.mIndices[0]].y, mesh-mVertices[face.mIndices[0]].z); fcl::Vector3d v1( mesh-mVertices[face.mIndices[1]].x, mesh-mVertices[face.mIndices[1]].y, mesh-mVertices[face.mIndices[1]].z); fcl::Vector3d v2( mesh-mVertices[face.mIndices[2]].x, mesh-mVertices[face.mIndices[2]].y, mesh-mVertices[face.mIndices[2]].z); model-addTriangle(v0, v1, v2); } } model-endModel(); return model; }aiProcess_Triangulate保证所有面都是三角形aiProcess_JoinIdenticalVertices合并重复顶点能减小BVH树的规模。OBBRSS是FCL推荐的包围盒类型兼顾紧密性和速度。4.2 构造碰撞对象与变换矩阵FCL里每个参与碰撞的物体是一个CollisionObject它包含几何模型和变换矩阵。变换矩阵用fcl::Transform3d表示本质是4x4的齐次矩阵。auto model1 loadSTL(part_a.stl); auto model2 loadSTL(part_b.stl); auto obj1 std::make_sharedfcl::CollisionObjectd(model1); auto obj2 std::make_sharedfcl::CollisionObjectd(model2); // 设置obj1在原点obj2沿X轴平移0.5米 fcl::Transform3d tf1 fcl::Transform3d::Identity(); fcl::Transform3d tf2 fcl::Transform3d::Identity(); tf2.translation() fcl::Vector3d(0.5, 0.0, 0.0); obj1-setTransform(tf1); obj2-setTransform(tf2);CollisionObjectd的d表示double精度。如果你的场景尺度很小比如毫米级用double没问题如果追求极致性能且精度要求不高可以用CollisionObjectffloat。4.3 执行碰撞检测并解析结果检测代码就几行但结果解析有讲究CollisionRequest request; request.num_max_contacts 10; request.enable_contact true; CollisionResult result; std::size_t num_contacts fcl::collide(obj1.get(), obj2.get(), request, result); if (num_contacts 0) { std::cout 碰撞发生接触点数量: num_contacts std::endl; for (const auto contact : result.contacts) { std::cout 位置: contact.pos.transpose() std::endl; std::cout 法向量: contact.normal.transpose() std::endl; std::cout 穿透深度: contact.penetration_depth std::endl; } } else { std::cout 无碰撞 std::endl; }contact.pos是接触点在世界坐标系下的位置contact.normal是碰撞法向量penetration_depth是穿透深度。如果两个物体只是刚好接触穿透深度接近0如果重叠很深这个值会比较大。做碰撞响应的时候穿透深度可以用来计算排斥力。4.4 距离查询与最近点计算有时候你不仅想知道“撞没撞”还想知道“离多远”。FCL的distance()函数能返回两个物体的最近距离和最近点DistanceRequest drequest; drequest.enable_nearest_points true; DistanceResult dresult; fcl::distance(obj1.get(), obj2.get(), drequest, dresult); std::cout 最小距离: dresult.min_distance std::endl; std::cout 最近点1: dresult.nearest_points[0].transpose() std::endl; std::cout 最近点2: dresult.nearest_points[1].transpose() std::endl;这个功能在路径规划里特别有用机器人要避开障碍物你得知道离障碍物最近的距离是多少才能判断路径是否安全。min_distance为负值时表示已经碰撞绝对值就是穿透深度。实操心得distance()比collide()慢因为要算精确的最近点。如果只是做碰撞预警可以先用collide()快速筛一遍只有接近的物体才做距离查询。5. 性能调优与常见问题排查5.1 BVH构建参数对速度的影响FCL构建BVH树的时候有几个参数直接影响检测速度。默认的BVHModel用自顶向下分割分割策略是取最长轴的中点。对于均匀分布的三角网格这个策略够用但如果模型有大量集中在某区域的三角形默认策略会导致树不平衡查询变慢。你可以通过model-bv_splitter自定义分割策略但更简单的办法是预处理模型用aiProcess_JoinIdenticalVertices合并顶点用aiProcess_OptimizeMeshes减少面片数。我实测过一个十万面的模型合并顶点后BVH构建时间从1.2秒降到0.4秒查询速度提升约30%。另一个参数是num_max_contacts。设成1和设成100检测时间能差两三倍。如果只做布尔判断千万别开enable_contact也别设大num_max_contacts。5.2 模型尺度与单位问题STL文件本身不带单位信息FCL也不关心单位。但你的场景如果混用了不同单位的模型碰撞结果就会莫名其妙。比如一个模型是毫米另一个是米明明应该碰撞的结果距离算出来是几百程序判断“无碰撞”。我的做法是在加载模型后统一缩放到米制。Assimp读出来的顶点坐标是原始值你可以遍历所有顶点乘以一个缩放系数。或者更简单在设置变换矩阵的时候把缩放因子写进矩阵fcl::Transform3d tf fcl::Transform3d::Identity(); tf.linear() fcl::Matrix3d::Identity() * 0.001; // 毫米转米注意缩放会改变法向量的长度如果后续要用contact.normal做计算记得归一化。5.3 常见编译错误与运行时异常我整理了几个高频问题基本覆盖了新手会踩的坑问题现象原因解决方法undefined reference to ccd*没链接libccdCMake里加-lccd或${CCD_LIBRARIES}Eigen/Core: No such fileEigen路径没配指定EIGEN3_INCLUDE_DIR运行时崩溃在beginModel模型指针为空检查STL是否加载成功碰撞结果始终为0变换矩阵没设置确认setTransform被调用检测速度极慢Debug模式或面片过多切Release简化模型contact.normal为零向量接触点法向量未计算确保enable_contacttrue还有一个隐蔽的坑FCL的collide()函数不是线程安全的。如果你在多线程里同时调得给每个线程独立的CollisionObject和CollisionResult或者加锁。我试过用OpenMP并行检测多个物体对结果随机崩溃后来改成每个线程复制一份对象才稳定。5.4 内存管理与智能指针使用建议FCL的CollisionObject持有几何模型的shared_ptr所以模型的生命周期由智能指针管理。但有个细节BVHModel在endModel()之后就不能再addTriangle了否则会抛异常。如果你需要动态更新模型得重新构建。另外CollisionResult里的contacts是值拷贝不是引用。如果num_max_contacts设得很大每次检测都会分配内存。高频检测场景下建议复用CollisionResult对象FCL会清空它再填充避免反复分配。6. 进阶玩法从单次检测到场景管理6.1 多物体场景的BroadPhase加速实际项目里不可能只有两个物体。几十个物体两两检测复杂度是O(n²)根本跑不动。FCL提供了BroadPhaseCollisionManager先用粗筛排除明显不相交的物体对再对剩下的做精确检测。常用的管理器有DynamicAABBTreeCollisionManager和NaiveCollisionManager。前者用动态AABB树适合物体频繁移动的场景后者就是暴力两两比较只适合物体很少的情况。auto manager std::make_sharedfcl::DynamicAABBTreeCollisionManagerd(); manager-registerObjects(collision_objects); manager-setup(); CollisionRequest request; CollisionResult result; manager-collide(result, request);manager-collide()会检测场景内所有物体对把碰撞结果汇总到result里。实测下来100个物体的场景用DynamicAABBTree比暴力检测快20倍以上。6.2 连续碰撞检测的实际应用做机械臂抓取的时候末端执行器从上方快速下落到工件表面如果只用离散检测可能这一帧还在上方下一帧已经穿进工件了。这时候就得用CCD。ContinuousCollisionRequest crequest; crequest.ccd_motion_type CCDM_TRANSFORM; crequest.gjk_solver_type GST_LIBCCD; ContinuousCollisionResult cresult; fcl::continuousCollide(obj1.get(), tf1_end, obj2.get(), tf2_end, crequest, cresult); if (cresult.is_collide) { std::cout 碰撞时间点: cresult.time_of_impact std::endl; }time_of_impact是0到1之间的值表示在运动过程的哪个时间点发生碰撞。0是起始位置1是终止位置。拿到这个值你可以让机械臂在碰撞前停下来或者调整轨迹。6.3 与路径规划算法的结合思路FCL本身不做路径规划但它可以作为规划算法的碰撞检查模块。比如RRT快速扩展随机树算法每次生成一个新节点就用FCL检查这个节点是否与障碍物碰撞。如果碰撞丢弃如果不碰撞加入树中。我做过一个六轴机械臂的避障规划用FCL做碰撞检查RRT做路径搜索。关键点是机械臂的每个连杆都要建一个CollisionObject相邻连杆之间不检测因为本来就连接在一起只检测非相邻连杆和障碍物。这样能把检测次数从O(n²)降到O(n)。实操心得机械臂的碰撞模型不要用原始STL面片太多。我一般用凸包或者简化后的网格检测速度快一个数量级精度也够用。7. 我踩过的坑和最后分享几个技巧第一个坑是STL法向量方向。有些STL文件的三角面片法向量朝内FCL虽然不直接用法向量做碰撞判断但计算接触点法向量时会受影响。如果发现contact.normal方向反了可以用Assimp的aiProcess_FlipWindingOrder翻转面片顺序。第二个坑是变换矩阵的更新时机。CollisionObject的setTransform只是更新了内部缓存如果你在collide()之前忘了调用的还是旧变换。我习惯在每次移动物体后立刻调setTransform避免遗漏。第三个坑是多线程下的内存竞争。前面提过FCL的collide()不是线程安全的。如果非要多线程每个线程独立创建CollisionObject和CollisionResult模型数据可以共享只读但对象和结果必须隔离。最后分享一个提速技巧如果场景里有很多静态障碍物可以把它们预先构建成一个大的BVHModel只用一个CollisionObject表示。这样检测动态物体和静态环境时只需要一次collide()调用比逐个障碍物检测快得多。我试过一个有200个静态障碍物的场景合并后检测时间从15毫秒降到0.8毫秒。代码仓库里我放了一个完整的示例包含STL加载、碰撞检测、距离查询和BroadPhase场景管理。你可以直接克隆下来改一下模型路径就能跑。如果遇到编译问题先检查Eigen和libccd的路径这两个是最容易出错的。