资讯动态

用CMake构建MuJoCo动力学计算:从单摆到可复用仿真工程

发布时间:2026/10/16 9:14:19 来源:尧图企业网站定制
简介MuJoCo多关节接触动力学与CMake构建系统结合的基础工程包面向机器人学、运动控制、强化学习等领域的开发者和研究者演示如何利用CMake对MuJoCo库进行编译配置并实现简单的动力学求解。压缩包内共计217个文件内容以CMake配置、C源码、头文件为主同时包含URDF/XML格式的机器人模型、STL三维网格文件、JSON/TXT辅助配置以及编译过程产生的动态库.so、静态库.a和中间目标文件.o覆盖了从源码编辑、构建配置到可执行程序生成的完整链路并附有Makefile和Ninja构建脚本方便对比学习不同构建方式整体压缩后大小约26.29MB。该资源已有310人学习适合刚接触MuJoCo与CMake集成的初学者或希望梳理工程化构建流程的工程师。借助资源中的模型文件、CMakeLists和示例代码可系统掌握CMake查找并链接库的写法、mj_loadXML加载模型数据和mj_step执行单步动力学求解的调用方法进而理解牛顿-欧拉方程在接触约束下的计算过程还能复用已编译好的库文件与构建系统省去从零搭建环境的繁琐步骤在此基础上开展更深层的应用开发。1. 用 CMake 构建 MuJoCo 简单动力学计算从单摆到可复用仿真工程C 开发者想把 MuJoCo 跑起来做动力学计算第一道坎往往是 CMake 配置而非物理公式本身。MuJoCo 的 API 设计清晰但依赖路径、编译选项、链接方式这些工程细节会让一个本来只需要几百行代码的项目卡上大半天。这篇文章用一个简单单摆模型作为起点带你从零搭一个 CMake 工程把模型加载、动力学步进、数据记录、传感器读取这些核心模块全部拆开讲透。项目本身不大但骨架是通用的——你可以在同一套工程结构里换成四连杆、机械臂、双足机器人甚至加进 PD 控制器和能量监控。这套流程适合两类人一类是想快速验证动力学算法的学生或研究员另一类是准备把 MuJoCo 集成进现有 C 系统的开发者。2. 项目结构与 CMake 配置一套可以复用的 MuJoCo C 工程模板2.1 为什么选择 CMake 作为构建系统MuJoCo 官方提供预编译库和源码两种集成方式而 CMake 是这两条路径都能平滑支持的工具。相比直接写 Makefile 或者手动在 IDE 里配 include 和 lib 路径CMake 的find_package和add_subdirectory机制能帮你在不同机器、不同操作系统上复用同一套构建脚本。我在本机测试过 Linux 和 Windows 两种环境这套配置基本不需要改就能直接编译通过。MuJoCo 的 C 接口其实是纯 C API 的薄封装核心计算函数如mj_step、mj_forward、mj_inverse都是 C 风格但 CMake 工程里链接时不会遇到 C 和 C 的符号混用问题因为官方头文件里已经做了extern C处理。真正容易踩坑的是库文件版本不匹配和头文件路径指向错误这些在 CMake 配置阶段就能提前暴露出来比编译到一半再报错要容易排查得多。2.2 目录结构与 CMakeLists.txt 逐行说明我把这个项目命名为mj_sim目录结构按照「第三方依赖、模型文件、源码」三层组织。MuJoCo 的模型文件用 XML 格式描述放在models目录下CMake 构建脚本和源码放在根目录和src目录。下面是一个最小可用的布局mj_sim/ ├── CMakeLists.txt ├── 3rdparty/ │ └── mujoco/ # MuJoCo 源码或预编译库所在目录 ├── models/ │ ├── simple_pendulum.xml │ └── spring_damper.xml └── src/ ├── main.cpp └── simulate.cppCMakeLists.txt 是工程配置的核心。我一般同时支持两种依赖获取方式优先查找系统已安装的 MuJoCo找不到时回退到本地源码编译。这个策略在新机器上部署时特别有用不用每次都手动安装依赖。下面是经过验证的脚本cmake_minimum_required(VERSION 3.16) project(mj_sim CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关闭 MuJoCo 自带的示例、仿真器和测试编译减少构建时间 option(MUJOCO_BUILD_EXAMPLES OFF) option(MUJOCO_BUILD_SIMULATE OFF) option(MUJOCO_BUILD_TESTS OFF) # 优先查找系统安装的 MuJoCo find_package(mujoco QUIET) if(NOT mujoco_FOUND) add_subdirectory(3rdparty/mujoco) endif() add_executable(sim src/main.cpp) target_link_libraries(sim PRIVATE mujoco)MUJOCO_BUILD_EXAMPLES、MUJOCO_BUILD_SIMULATE、MUJOCO_BUILD_TESTS三个选项默认是 ON如果不显式关掉CMake 会多编译几百个源文件耗时增加很多。find_package(mujoco QUIET)用 QUIET 模式是为了不打扰控制台输出找不到时不会报错中断直接走下面add_subdirectory的源码编译路径。链接时只需要写mujoco这个 target 名CMake 会自动带出头文件路径和库文件路径。2.3 主程序框架模型加载与数据初始化MuJoCo 的数据结构核心是mjModel和mjData两个结构体。mjModel是整个物理模型的静态描述包含几何体、关节、执行器、传感器、碰撞对等所有定义仿真过程中只读mjData是每一步仿真产生的可变状态包括广义坐标qpos、广义速度qvel、加速度、接触力、时间戳等。每次调用mj_step就是推进一个固定步长的物理仿真。下面这段代码完成了模型加载、数据创建、初始条件设置和基础仿真循环是整个项目最核心的入口逻辑#include mujoco/mujoco.h #include iostream #include vector int main() { // 加载 XML 模型文件 mjModel* model mj_loadXML(models/simple_pendulum.xml, nullptr, nullptr, 0); if (!model) { std::cerr 模型加载失败请检查 XML 文件路径 std::endl; return 1; } // 创建与模型配套的仿真数据 mjData* data mj_makeData(model); std::cout 自由度数量: model-nq std::endl; std::cout 身体数量: model-nbody std::endl; // 重置仿真状态并设置初始关节角度 mj_resetData(model, data); >if(WIN32) target_compile_definitions(sim PRIVATE MUJOCO_DLL) endif()这个宏告诉头文件当前是在链接动态库而不是静态库编译器和链接器就能正确对应符号。另外 Windows 下 CMake 默认会开启/W4级别的警告MuJoCo 的纯 C 头文件在 C 工程里编译会产生一些类型转换警告但不影响链接。如果想保持输出干净可以针对mujocotarget 单独关闭警告等级不需要在全局设置。3. 核心模块拆解单摆动力学、传感器数据读取与能量监控3.1 物理模型定义与初始条件注入单摆是动力学计算最简单的非平庸系统它只有两个状态变量角度和角速度但已经能用来验证积分器精度、控制器稳定性这些重要问题。MuJoCo 的 XML 模型文件把物理参数全部放在可读文本里下面是配套本项目的最小单摆模型定义mujoco modelsimple_pendulum option timestep0.002 integratorRK4/ worldbody light nametop pos0 0 1/ body namependulum pos0 0 1 joint namehinge typehinge axis0 0 1/ !-- 绕 z 轴旋转的铰链关节 -- geom namerod typecapsule size0.01 0.5 pos0 0.5 0 rgba0.8 0.2 0.2 1/ geom namesphere typesphere size0.05 pos0 1 0 rgba0.2 0.2 0.8 1/ /body /worldbody /mujoco铰链关节的旋转轴设为axis0 0 1表示绕世界坐标系的 z 轴旋转。capsule是胶囊体几何类型用半径和半长度描述这里模拟摆杆sphere是摆锤末端质量。MuJoCo 的几何体可以绑定在同一 body 下运动学上它们联动但碰撞检测时会分别计算。这个模型在工程里被多个仿真任务复用不同实验只修改option里的timestep和integrator不需要动代码。3.2 关节约束与 PD 控制器的 C 实现动力学计算不只有被动仿真主动控制才是大多数项目的目标。MuJoCo 的执行器模型支持三种类型motor电机、position位置伺服和velocity速度伺服。我用一个弹簧阻尼模型来演示控制器的接入方式它在 XML 中定义一个motor执行器然后在仿真循环里通过>#include mujoco/mujoco.h #include cmath #include vector struct ControllerState { double target_pos; // 目标关节角度 double target_vel; // 目标角速度跟踪时通常为 0 double kp; // 比例增益 double kd; // 微分增益 }; void apply_pd_controller(mjModel* m, mjData* d, const ControllerState ctrl) { if (m-nu 1) return; // 读取当前关节位置和速度 double q d-qpos[0]; double v d-qvel[0]; // PD 控制律力矩 kp * 位置误差 kd * 速度误差 double tau ctrl.kp * (ctrl.target_pos - q) ctrl.kd * (ctrl.target_vel - v); // 写入控制输入数组 d-ctrl[0] tau; } int main() { mjModel* m mj_loadXML(models/spring_damper.xml, nullptr, nullptr, 0); mjData* d mj_makeData(m); // 设置初始位置和速度 mj_resetData(m, d); d-qpos[0] 0.1; d-qvel[0] 0.0; // 配置控制器参数 ControllerState ctrl {1.0, 0.0, 100.0, 10.0}; std::vectordouble error_log; for (int i 0; i 5000; i) { apply_pd_controller(m, d, ctrl); mj_step(m, d); error_log.push_back(std::fabs(ctrl.target_pos - d-qpos[0])); } // 输出最大稳态误差 double max_err *std::max_element(error_log.begin(), error_log.end()); std::cout 最大跟踪误差: max_err std::endl; mj_deleteData(d); mj_deleteModel(m); return 0; }d-ctrl数组长度由 XML 中定义的总执行器数量决定每个执行器对应一个控制输入值。mj_step调用时会读入ctrl作为外力项参与动力学计算。PD 参数的选择直接影响控制效果kp100、kd10时系统稳定收敛把kp降到 10 会出现明显的超调和震荡kd过小则系统响应快但稳定性差。这段代码适合做控制算法的起点把target_pos替换成随时间变化的轨迹就能做轨迹跟踪实验。3.3 传感器数据读取与 CSV 输出动力学计算的产出不只是数字通常需要把状态量导出成文件供后续处理。MuJoCo 的传感器系统在 XML 中声明计算完成后自动写入>#include mujoco/mujoco.h #include fstream #include iomanip int main() { mjModel* m mj_loadXML(models/sensor_demo.xml, nullptr, nullptr, 0); mjData* d mj_makeData(m); std::ofstream out(sensor_log.csv); out step,time,force_x,force_y,force_z\n; mj_resetData(m, d); d-qpos[0] 0.05; for (int i 0; i 2000; i) { mj_step(m, d); // 假设第一个传感器是 3 维力传感器 if (m-nsensor 0) { int addr m-sensor_adr[0]; double fx d-sensordata[addr]; double fy d-sensordata[addr 1]; double fz d-sensordata[addr 2]; out i , d-time , fx , fy , fz \n; } } out.close(); mj_deleteData(d); mj_deleteModel(m); return 0; }sensor_adr[0]是第一个传感器的数据起始偏移三维力传感器占用 3 个连续 double 值。如果你的模型里有多个传感器手动索引容易出错更稳妥的办法是用mj_name2id(m, mjOBJ_SENSOR, sensor_name)拿到传感器编号再通过sensor_adr[sensor_id]定位数据。我在一个多体机器人模型上就吃过索引错位的亏后来封装了一个按名字读传感器数值的函数再没出过问题。3.4 能量监控与数值稳定性验证数值积分的稳定性是动力学仿真绕不开的话题。对于保守系统总能量应当保持不变如果能量曲线持续上升或下降说明步长太大或者积分器阶数不够。MuJoCo 不直接提供能量计算接口但可以从qvel和dof_armature组装出动能项。double compute_kinetic_energy(mjModel* m, mjData* d) { // dof_armature 是每个自由度的转子惯量 // 注意这只是简化计算完整动能需要质量矩阵参与 double ke 0.0; for (int i 0; i m-nv; i) { ke 0.5 * m-dof_armature[i] * d-qvel[i] * d-qvel[i]; } return ke; }我这套简化计算在低自由度模型上精度够用但如果你做刚体空间运动建议直接用 MuJoCo 的mj_kineticEnergy函数它内部用完整质量矩阵计算结果更准确。能量判断的标准是相对误差不超过 0.1%超过了就优先调小timestep。我把timestep从 0.002 调到 0.01 时单摆能量误差明显变大形状也从圆滑的衰减变成了锯齿状这就是数值耗散加重的信号。4. 避坑指南MuJoCo CMake 项目实战中的常见问题和排查手段4.1 链接阶段找不到mj_step符号新环境里第一次构建 CMake 项目时链接器报错undefined reference to mj_step是最高频的问题。原因通常不是代码写错而是库文件和头文件版本不匹配或者链接路径没指对。排查步骤是先确认 CMake 缓存里mujoco_INCLUDE_DIR和mujoco_LIBRARY的实际值如果指向了系统里另外一份旧版本库最省事的办法是删掉 CMakeCache.txt 重新构建或者直接用源码编译路径绕开系统库。我在一台装有多个版本 MuJoCo 的开发机上遇到过两次这个问题都是因为环境变量MUJOCO_DIR指的旧路径盖过了新安装。4.2mj_loadXML返回空指针但没有任何错误输出mj_loadXML失败时默认不打印错误信息返回nullptr程序继续访问model-nq就会崩。MuJoCo 提供错误回调机制可以通过mj_interface或者设置全局错误函数来捕获详细原因。我的排查习惯是先在模型文件上做最小测试用一个三行程序只加载不仿真如果依然返回空就检查 XML 语法。XML 里容易忽略的坑包括几何体引用了未知的 mesh 名称、关节的axis向量长度不为 1、两个 body 之间存在循环父子依赖。模型文件路径用相对路径时要在 CMake 构建目录的正确位置运行程序否则相对路径解析会扑空。4.3 仿真结果与理论解析解偏差过大拿单摆举例它在小角度近似下有精确的周期公式。如果你用 MuJoCo 算出来的周期和解析解相差超过 5%第一步检查步长调小timestep看结果是否收敛第二步检查积分器RK4 的精度比 Euler 高一阶两种积分器的对比误差能差出两个数量级。第三步检查初始条件qpos[0]的单位是弧度不是度我曾经因为传入 90 而不是 1.57 导致角度直接超出关节限位仿真出来的轨迹完全不对。4.4 Windows 下编译报C4996弃用警告VS 编译时 MuJoCo 头文件触发strcpy弃用警告严重级别是Error时会直接中断编译。这里我用的方案是关闭将警告视为错误的全局设置把_CRT_SECURE_NO_WARNINGS加入预处理器定义。不过要注意这个宏要加在target_compile_definitions里而不是源代码里不然所有包含 MuJoCo 头文件的源文件都要重复定义维护成本高且容易漏。4.5sensordata索引越界导致数据错乱多个传感器同时存在时手动加索引是定时炸弹。sensordata数组长度是所有传感器维度的总和sensor_adr给出每个传感器的起点sensor_dim给出维度。正确做法是先拿传感器编号再取地址int sensor_id mj_name2id(m, mjOBJ_SENSOR, force_sensor); if (sensor_id 0) return; int addr m-sensor_adr[sensor_id]; int dim m-sensor_dim[sensor_id];不要把addr直接当作全局偏移量手写死因为你在 XML 里加一个新传感器所有后续索引全部往后挪排错过程非常痛苦。5. 二次开发指南封装仿真类、参数批处理与数据对接5.1 用 RAII 类封装 mjModel 和 mjData 生命周期把模型加载、状态重置、步进、销毁封装进一个类是工程化的第一步。C 开发者直接把mjModel*和mjData*到处传容易在某个分支里忘记调用mj_deleteData造成内存泄漏。下面这个MjSim类把常用操作收敛成几个直观方法#include mujoco/mujoco.h #include stdexcept #include vector class MjSim { public: explicit MjSim(const std::string xml_path) { model_ mj_loadXML(xml_path.c_str(), nullptr, nullptr, 0); if (!model_) throw std::runtime_error(model load failed: xml_path); data_ mj_makeData(model_); } ~MjSim() { if (data_) mj_deleteData(data_); if (model_) mj_deleteModel(model_); } // 删除拷贝构造和赋值防止同一模型被多个实例管理 MjSim(const MjSim) delete; MjSim operator(const MjSim) delete; void reset(const std::vectordouble qpos) { mj_resetData(model_, data_); for (size_t i 0; i qpos.size() i (size_t)model_-nq; i) { data_-qpos[i] qpos[i]; } } void step() { mj_step(model_, data_); } double qpos(int idx) const { return data_-qpos[idx]; } double qvel(int idx) const { return data_-qvel[idx]; } double time() const { return data_-time; } mjModel* model() { return model_; } mjData* data() { return data_; } private: mjModel* model_ nullptr; mjData* data_ nullptr; };这个类的外部接口很简单控制算法代码几乎不需要知道 MuJoCo 的数据结构内部长什么样。RAII 析构保证异常路径上内存也会释放不会因为return早退而泄漏。reset方法同时支持多自由度模型传入的qpos数组长度小于模型自由度时剩余部分自动保持零值。5.2 多线程参数扫描动力学计算经常要做批量实验比如不同阻尼系数下的响应对比。MuJoCo 的mjModel可以安全共享但mjData每个线程必须独立持有因为内部有工作区状态。下面的代码用不同kp值并行跑多组仿真#include thread #include iostream #include vector void run_single_condition(const std::string xml_path, double kp) { MjSim sim(xml_path); sim.reset({0.1, 0.0}); // 注意kd 是构造函数里默认的这里只改 kp // 通过 model() 直接访问阻尼系数仿真前一次性修改 sim.model()-dof_damping[0] kp; for (int step 0; step 5000; step) { sim.step(); } std::cout kp kp final_qpos sim.qpos(0) std::endl; } int main(int argc, char* argv[]) { std::string xml_path models/spring_damper.xml; std::vectordouble kp_values {10.0, 50.0, 100.0, 200.0}; std::vectorstd::thread threads; for (double kp : kp_values) { threads.emplace_back(run_single_condition, xml_path, kp); } for (auto t : threads) t.join(); return 0; }这里有个值得注意的点dof_damping是mjModel里的属性仿真开始前一次性修改是安全的但不要在mj_step循环内部改否则多个线程同时写同一个mjModel会产生数据竞争。如果你要控制每组仿真用不同的步长需要在构造MjSim后单独设置model()-opt.timestep同样的道理必须在步进之前完成。这个多线程模式在单摆、弹簧阻尼器这类低自由度模型上能直接把吞吐量提升到秒级千组以上。5.3 与 Python 数据分析脚本无缝对接C 仿真程序的输出直接定位为标准 CSV 文件Python 端用 pandas 读取。我一般固定输出time,qpos,qvel,energy四个字段这样不依赖任何解析库就能画图。下面是 C 端写文件的一个小片段放在仿真循环内部// 仿真循环内部 out d-time , d-qpos[0] , d-qvel[0] , compute_kinetic_energy(m, d) \n;Python 端读取时注意跳过表头并把时间列转成浮点数。这个流程的好处是 C 和 Python 完全解耦仿真程序可以跑在服务器上数据分析在笔记本上完成中间只传递 CSV 文件。我做参数扫描时Python 脚本直接循环调用编译好的 sim 可执行文件每次传不同的命令行参数输出文件自动按参数命名汇总后一次性画出对比曲线。5.4 运行时参数修改入口把仿真参数暴露成命令行选项是把演示程序变成工具的关键一步。MuJoCo 的几个核心公开参数非常适合做运行时配置model-opt.timestep控制仿真步长、model-opt.gravity调整重力向量、model-opt.integrator切换积分器。下面这个函数从命令行解析模型路径、输出路径、仿真步数和初始状态生产环境里可以直接被脚本调度int main(int argc, char* argv[]) { if (argc 5) { std::cerr Usage: sim xml output.csv steps dt [init_qpos...] std::endl; return 1; } std::string xml_path argv[1]; std::string output_path argv[2]; int steps std::stoi(argv[3]); double dt std::stod(argv[4]); std::vectordouble init_qpos; for (int i 5; i argc; i) { init_qpos.push_back(std::stod(argv[i])); } MjSim sim(xml_path); sim.model()-opt.timestep dt; sim.reset(init_qpos); std::ofstream out(output_path); for (int step 0; step steps; step) { sim.step(); out sim.time() , sim.qpos(0) , sim.qvel(0) \n; } return 0; }这版 main 函数把MjSim类和命令行解析组合起来既适合手工单次运行也适合被 Python 脚本循环调用。如果你还要批量扫描kp、kd这类控制器参数可以在命令行后面追加键值对格式例如--kp 100 --kd 10然后逐项解析。我通常在仿真程序里预留一个通用键值对解析函数新增参数时只需要注册一个映射关系不用改主流程的调用逻辑。6. 从单摆到自动化实验动力学计算工具的工程化演进6.1 让程序变成可配置工具的最后一步把MjSim类、CSV 输出、命令行解析组合到一起之后这个项目就从「演示单摆」变成了「可复用的动力学计算工具」。我在这类项目上通常还要加一个简单的 JSON 配置入口用文件描述仿真参数和需要覆盖的 MuJoCo 属性这样不做 C 开发的人也能改配置跑实验。标准库没有 JSON 解析支持我会引入单头文件的nlohmann/jsonCMake 里只需要一行target_include_directories就能搞定对构建系统没有额外负担。配置结构大致长这样{ xml_path: models/simple_pendulum.xml, output_file: result.csv, steps: 10000, timestep: 0.002, integrator: RK4, init_qpos: [1.57] }解析逻辑本质上是字符串匹配把integrator字符串转成 MuJoCo 的枚举值把init_qpos数组填进reset函数。这样每次实验只需要改 JSON 文件不需要重新编译批量实验时 Python 脚本生成不同配置再逐个调用整个过程完全自动化。我在一次机械臂轨迹仿真任务里用这种方式跑了上百组参数全部脚本驱动没有碰过一行代码。6.2 性能调优的几个实用经验如果你跑大规模仿真前面这些基础功能还要过一遍性能调优。第一传感器计算有额外开销不需要时尽量从 XML 里删掉传感器声明第二mjData的sensordata、qpos、qvel数组访问用局部指针缓存避免每次循环都做一次指针解引用第三没有可视化需求时在 CMake 里关闭渲染相关选项减少编译时间和运行时开销第四批量仿真时反复创建和销毁MjSim实例比reset复用慢很多尽量把实例保持到全部参数跑完。性能优化的效果在低自由度模型上不明显但模型一复杂这些习惯的好处就体现出来了。某次我在一个包含 10 个 body、6 个关节和 3 个力传感器的模型上做优化把传感器去掉并把步长从 0.0005 调整到 0.002 之后同样的仿真时长计算时间缩短了 40% 以上。6.3 可视化与调试的建议可视化不是必须的但一旦模型复杂没有可视化的调参会非常痛苦。MuJoCo 编译时开启MUJOCO_BUILD_SIMULATE会生成一个官方的仿真器不过那是完整的大程序集成进自己的工程比较笨重。我更常用的做法是先用打印调试输出nq和nbody确认模型加载正确再跑 10 步打印每一步的qpos和qvel观察数值是否符合物理直觉最后才挂可视化。这个流程让我避免了很多「程序能跑但结果完全错误」的翻车现场。6.4 从第一个可执行文件到工程化流程的感悟回看这个项目的演进路径从一个人手动的单摆仿真变成了支持命令行参数、批处理、多线程并行、CSV 输出和配置文件的通用动力学计算框架。关键不在某一段代码有多精巧而在于把「仿真」这件事拆成了模型加载、初始条件设置、步进循环、数据记录、参数配置这几个独立模块每个模块都能单独替换和扩展。我最初做 MuJoCo 项目时也踩了不少坑链接失败、传感器索引越界、能量发散都遇到过后来把每一步的通用逻辑收敛成MjSim类把输入输出格式固定下来开发效率明显提升。从那以后我每接手一个动力学仿真任务都会强制先花半小时搭好 CMake 工程结构、把数据格式定好再开始写核心业务逻辑。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑