☰
CMake集成MuJoCo做前向动力学:从构建到单摆验证
2026/10/14 14:21:30 网站建设 项目流程

简介:面向需要在C++工程中集成MuJoCo并进行基础动力学计算的开发者,这是一份CMake构建MuJoCo项目的实操资源包,适用于机器人学、运动控制与强化学习仿真场景。压缩包共217个文件、约26.29MB,包含cmake/make构建配置、cpp/h源码、xml/urdf模型描述、stl网格模型、js辅助资源,以及编译生成的库文件与可执行产物;从源代码、构建脚本到最终产出均可对照阅读,便于理解整个仿真项目的组成与生成过程。已有310人学习。通过资源中的CMakeLists配置、MuJoCo API调用示例和配套模型文件,读者可以掌握find_package查找库、链接Mujoco::mujoco、用mj_loadXML加载模型并循环执行mj_step完成动力学求解的完整流程,同时还能利用打包好的运行库直接复现工程。对于希望快速搭起MuJoCo+CMake项目骨架、排查构建链接问题、梳理物理仿真代码组织的学习者,这份现成模板能显著缩短上手时间。

1. 把 MuJoCo 装进 CMake 工程:为什么“简单的动力学计算”要先过构建这关

你想用 MuJoCo 算一个简单动力学数值,比如单摆的角加速度,却很可能先被 CMake 劝退:源码包放进工程后 add_subdirectory,头文件找不到,链接符号对不上,最后还得去翻库里的 CMakeLists 看导出目标名到底是什么。这几乎是每个新用户遇到的第一个坑。实际上,只要把 MuJoCo 作为一个子目录集成进自己的 CMake 工程,加载模型后用一次mj_forward调用,就能拿到广义加速度qacc,计算本身并不复杂。这篇文章从构建讲起,给你一份能编译的最小 CMakeLists,再逐步做到前向动力学计算、状态赋值和参数验证。适合已经会写 C++/CMake、想做机器人或物理仿真动力学分析的人,不需要图形渲染经验。

2. 用 CMake 把 MuJoCo 编译进本地工程:最小配置与关键开关

2.1 源码与依赖:落位 third_party 还是 find_package

往自己工程里接 MuJoCo,常见做法有两种:一种是源码级依赖,把 MuJoCo 源码包放进third_party/目录,再用add_subdirectory编进来;另一种是系统级安装后用find_package(mujoco REQUIRED)链接。对“简单动力学计算”这个目标,我更推荐前者。原因很实际:MuJoCo 版本迭代快,不同版本对编译器版本和依赖库的要求有差异,源码子目录方式能把版本锁死在你的仓库里,换机器时重新拉依赖即可,不会污染系统环境。

前置条件比较简单:一个支持 C++17 的编译器,GCC 9 以上或者 Clang 10 以上即可,MSVC 2019 也可以;CMake 版本建议 3.16 以上。MuJoCo 本身会带一些编译期依赖,比如 OpenGL、SDL 这些跟渲染窗口相关的库。如果你只是在无显示环境里做动力学计算,这些依赖完全可以通过 CMake 选项关掉,后面我会给出具体写法。

关掉渲染模块不会影响mj_forward、mj_step这些核心数值计算函数。我经常在服务器容器里只关 OpenGL 编一个纯动力学的可执行文件,编译时间能缩短不少。需要注意的是,一旦关闭 OpenGL,MuJoCo 的mj_visualize相关接口就不可用,但这跟我们要算的qacc没有任何关系。

落地目录结构我一般这样摆:

project/ CMakeLists.txt main.cpp third_party/ mujoco/ # MuJoCo 源码包,目录名自己定

拿到源码包之后,第一件事不是直接写 CMakeLists,而是看一眼third_party/mujoco/CMakeLists.txt里的库目标名。不同版本的导出目标可能是mujoco::mujoco,也可能是Mujoco::mujoco,这决定了你后面target_link_libraries怎么写。如果没找到,就去构建目录里搜索ALIAS关键字,构建时 CMake 通常会把别名目标输出在cmake_install.cmake或生成的*.cmake文件里。

2.2 最小 CMakeLists.txt:add_subdirectory、cache 变量与目标名

这里给出一份可直接复制的最小 CMakeLists。它只做了一个事:把 MuJoCo 编译进来,然后链接给一个叫dynamics的可执行目标。

cmake_minimum_required(VERSION 3.16) project(mj_dynamics LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 关闭与动力无关的模块,缩短编译时间 set(MUJOCO_BUILD_PYTHON OFF CACHE BOOL "" FORCE) set(MUJOCO_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(MUJOCO_BUILD_SIMULATE OFF CACHE BOOL "" FORCE) set(MUJOCO_ENABLE_OPENGL OFF CACHE BOOL "" FORCE) add_subdirectory(third_party/mujoco) add_executable(dynamics main.cpp) target_link_libraries(dynamics PRIVATE mujoco::mujoco)

前四行set(... CACHE BOOL "" FORCE)是关键。这几个变量如果不显式设置,MuJoCo 的默认配置会把 Python 绑定、示例程序、仿真器等全部编一遍,既慢又容易出现依赖缺失。用FORCE是为了覆盖掉变量在缓存里可能存在的旧值,保证每次重新配置都是同一套选项。

最后一行target_link_libraries是整个集成的核心。链接隐藏目标mujoco::mujoco时,CMake 会把 MuJoCo 的头文件目录、编译宏一并传递给dynamics这个目标,不需要你再手动include_directories。所以第 2.1 节里确认过目标名后,这行代码基本就是全部集成成本。

有一点要提醒:如果你看到的目标名不是mujoco::mujoco而是别的,不要硬改命名空间,直接去源码 CMakeLists 搜add_library和ALIAS,把找到的目标名填进target_link_libraries即可。硬编码不存在的目标名,CMake 会在配置阶段直接报错。

2.3 首次构建与运行:最小程序怎么验证可计算

写一个最简main.cpp,只加载模型并打印模型的自由度个数。这一步能快速区分“构建问题”和“动力学问题”,先确认库本身能编译能链接,再谈计算。

#include <mujoco/mujoco.h> #include <cstdio> int main(int argc, char** argv) { if (argc < 2) { std::fprintf(stderr, "usage: %s <model.xml>\n", argv[0]); return 1; } char err[1024] = {0}; mjModel* m = mj_loadXML(argv[1], nullptr, err, sizeof(err)); if (!m) { std::fprintf(stderr, "XML error: %s\n", err); return 1; } mjData* d = mj_makeData(m); std::printf("model loaded: nq=%d nv=%d nu=%d\n", m->nq, m->nv, m->nu); mj_deleteData(d); mj_deleteModel(m); return 0; }

这里nq是广义坐标维度,nv是广义速度维度,nu是执行器数量。对于单摆这种一个铰链关节的模型,nq=1、nv=1通常是预期值。nq和nv不相等也很正常,比如有欧拉角表示旋转的关节时nq会比nv大。

编译命令比较常规:

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j4 ./build/dynamics pendulum.xml

-DCMAKE_BUILD_TYPE=Release建议每次都带,因为 MuJoCo 的动力学求解涉及密集的矩阵运算,Release 模式下的优化对计算耗时影响非常明显。如果看到nq=1 nv=1 nu=0,说明构建链路已经通了,接下来可以进入真正的动力学计算。

3. 前向动力学的计算闭环:mjModel、mjData 与 mj_forward

3.1 用 XML 描述一个单摆:模型文件怎么选

动力学计算先从模型文件开始。MuJoCo 的模型格式是 MJCF,本质是 XML。最小可运行的单摆模型可以这样写:

<mujoco model="simple_pendulum"> <option gravity="0 0 -9.81"/> <worldbody> <body name="link" pos="0 0 0"> <joint name="pin" type="hinge" axis="0 0 1"/> <geom name="point" type="sphere" size="0.02" pos="0.1 0 0" mass="0.1"/> </body> </worldbody> </mujoco>

这里body是运动链条上的一个刚体,它的原点在(0,0,0);joint定义了一个铰链,旋转轴是 z 轴;geom是质量属性载体,位置在局部坐标(0.1,0,0),也就是说质心距离旋转轴 0.1 米。gravity明确写成垂直向下,避免依赖库里的默认值。

把geom用成一个小球而不是细杆,是为了验证方便:球的半径很小,自转惯量对整体转动惯量影响可以忽略,这样解析解和数值解能对得上。如果换成胶囊体或者立方体,虽然模型更接近真实摆,但验证公式要额外算惯性张量,对新手不太友好。

3.2 mj_loadXML 与 mj_makeData:静态模型与动态数据的分工

加载模型时,mjModel存的是所有“不随状态变化”的信息:关节位置、几何尺寸、质量、惯性张量、执行器参数等。mjData存的是“每一帧都会变”的信息:关节位置、速度、加速度、力、约束状态等。

#include <mujoco/mujoco.h> #include <cstdio> int main(int argc, char** argv) { char err[1024] = {0}; if (argc < 2) { std::fprintf(stderr, "usage: %s <model.xml>\n", argv[0]); return 1; } mjModel* m = mj_loadXML(argv[1], nullptr, err, sizeof(err)); if (!m) { std::fprintf(stderr, "load failed: %s\n", err); return 1; } mjData* d = mj_makeData(m); // 通过名字拿到关节 id,比硬编码索引稳妥 int jid = mj_name2id(m, mjOBJ_JOINT, "pin"); if (jid < 0) { std::fprintf(stderr, "joint not found\n"); return 1; } // jnt_qposadr 和 jnt_dofadr 给出这个关节在 qpos/qvel 中的起始索引 int qadr = m->jnt_qposadr[jid]; int vadr = m->jnt_dofadr[jid]; std::printf("joint axis index: qadr=%d, vadr=%d\n", qadr, vadr); mj_deleteData(d); mj_deleteModel(m); return 0; }

mjLoadXML第二个参数传空指针,表示不指定模型回调;如果有需要,可以在那里传一个mjvOption之类的结构,但计算场景基本用不到。加载失败时,错误信息会写入err,最多 1024 字节,务必检查返回值而不是假定成功。

值得强调的是jnt_qposadr和jnt_dofadr。MuJoCo 内部在求解动力学前会对自由度重新排序,关节声明的顺序不等于qpos里的顺序。即使这个模型只有一个关节,硬编码索引 0 没问题,但一旦模型有多个关节,索引错位会直接导致计算结果毫无意义。所以养成通过mj_name2id查 id 的习惯,后面写复杂模型就不会翻车。

3.3 一次调用 mj_forward:前向动力学到底做了什么

mj_forward是 MuJoCo 里最核心的数值函数。它接受当前状态qpos、qvel,以及外部输入ctrl、xfrc_applied等,然后完成正向运动学、质量矩阵组装、约束求解,最终把广义加速度写入d->qacc。

用一句话概括:给定这一时刻的位置、速度和输入力矩,算出这一刻的加速度。这个加速度可以直接用来做积分,也可以用来分析某个构型下的动力学特性。

// 在加载完模型、创建完 data 之后 d->qpos[0] = 0.5; // 让摆偏转 0.5 弧度 d->qvel[0] = 0.0; // 初始角速度为 0 mj_forward(m, d); // 计算当前状态下的广义加速度 std::printf("qacc[0] = %.6f\n", d->qacc[0]);

这段代码的顺序很重要:必须先给qpos、qvel赋值,再调用mj_forward。如果你先调mj_forward再改状态,qacc里存的就是修改前的计算结果。另外注意qpos的单位是弧度,qvel的单位是弧度每秒,不是角度。

mj_forward没有返回值,它把计算结果直接写进mjData。这样做的好处是调用栈简单,不需要额外的输出参数;坏处是很多人第一次用的时候容易到处找返回值,找半天才发现数据都在d->qacc里。

3.4 用解析解验证数值结果:先对照再放心

拿到qacc[0]只是第一步。怎么知道这个数算得对不对?单摆模型有个现成的解析公式:角加速度等于-g/l * sin(theta)。在上面这个模型里质心距离旋转轴0.1米,重力加速度取9.81,所以期望值约等于-98.1 * sin(theta)。

#include <cmath> double theta = 0.5; double g = 9.81; double l = 0.1; double alpha_expected = -g / l * std::sin(theta); std::printf("mujoco : %.6f\n", d->qacc[0]); std::printf("analytic : %.6f\n", alpha_expected);

运行结果会和解析解差 1% 左右,原因是小球自身有转动惯量,2/5 * m * r^2这一项在整体惯量里虽然小但不为零。如果这个差异让你不安,可以把球的size改到0.002,偏差会进一步缩小到 0.01% 量级。这种“自己动手验证”的习惯建议保留,尤其是当后面换成多自由度模型时,手算验证不太现实,但至少可以先在小模型上确认你自己的调用方式正确。

4. 动力学计算避坑:五个高频问题与处理顺序

4.1 现象:mj_loadXML 失败,错误信息只说 XML 解析有问题

原因:最常见是文件路径不对,或者 XML 根节点写错。mj_loadXML对路径很敏感,工作目录不同,相对路径就失效。另一个隐蔽原因是 XML 文件编码,MuJoCo 的解析器对 UTF-8 支持最好,带 BOM 的文件容易时报无法识别。

解决:先在代码里打印argv[1],确认实际读到的是哪个路径;再检查 XML 首行是不是<mujoco>开头。如果路径复杂,建议把模型文件和可执行文件放在同一目录下,用相对路径调试。遇到 BOM 问题,用编辑器把文件另存为 UTF-8 without BOM。

4.2 现象:qacc 一直是 0,怎么改变状态都没有反应

原因:有两个常见可能。第一,mj_forward调用发生在qpos、qvel赋值之前,计算时状态还未更新;第二,XML 里显式写了gravity="0 0 0",在没有任何外力的情况下,单摆的加速度确实为 0,这其实是正常结果。

解决:把赋值顺序调整为先写qpos、qvel,再调mj_forward。同时打印m->opt.gravity的实值,确认重力方向不是零向量。我习惯在模型加载后立刻输出一次m->opt.gravity,避免用了默认配置还以为自己设了重力。

4.3 现象:多个关节时索引错位,读出来的 qacc 值明显不对

原因:MuJoCo 的qpos和qvel顺序不是按模型 XML 中关节书写顺序排列的。内部会针对运动学树做自由度编号优化,尤其在带有自由关节、球关节或全局坐标系时,关节 id 和数组索引可能差得远。

解决:不要用“第几个关节就取第几个数”的方式。改用mj_name2id拿到关节 id,再通过m->jnt_qposadr[jid]和m->jnt_dofadr[jid]获得真实索引。这个习惯虽然多写两行代码,但换模型、加关节时几乎不用改逻辑。

4.4 现象:链接时找不到 mujoco target,报 target not found

原因:不同版本的 MuJoCo 导出的 CMake 目标名不完全相同,可能是mujoco::mujoco,也可能是Mujoco::mujoco,或者直接是mujoco。如果没确认就照抄别人的写法,CMake 会在配置阶段直接失败。

解决:在add_subdirectory之后打开构建目录里的CMakeCache.txt,搜mujoco相关的键值,确认实际生成的库目标名。也可以看 MuJoCo 源码的CMakeLists.txt里add_library那几行的命名。确认后再回写target_link_libraries。

4.5 现象:数值结果和手算解析解差百分之几,角度越大误差越大

原因:几何体自转惯量没有进解析式。你在推导时只算了m*l^2,但 MuJoCo 计算惯性张量时包含了球体绕自身轴的转动惯量,这部分随角度不变,但会让实际角加速度偏小。

解决:把几何体半径改小,比如从0.02改到0.002,重新解析验证。如果差的是固定比例,说明你漏了某项常数;如果差值随角度非线性变化,就要检查是不是把铰链轴向写错了。

5. 从单步计算到可复现实验:参数扫描与轨迹输出

5.1 用命令行参数控制初始状态:一次运行算多个工况

单摆只有一个自由度,手动改qpos就能算不同初始角度。但真实场景往往需要批量扫描不同位形或不同速度,比如看一支机械臂在不同关节角组合下的加速度响应。这时候把初始状态参数化最方便。

// run_dyn <model.xml> <qpos> <qvel> #include <mujoco/mujoco.h> #include <cstdio> #include <cstdlib> int main(int argc, char** argv) { if (argc < 4) { std::fprintf(stderr, "usage: %s <model.xml> <qpos> <qvel>\n", argv[0]); return 1; } char err[1024] = {0}; mjModel* m = mj_loadXML(argv[1], nullptr, err, sizeof(err)); if (!m) return 1; mjData* d = mj_makeData(m); double q0 = std::atof(argv[2]); double v0 = std::atof(argv[3]); d->qpos[0] = q0; d->qvel[0] = v0; mj_forward(m, d); std::printf("theta=%f v=%f qacc=%f\n", q0, v0, d->qacc[0]); mj_deleteData(d); mj_deleteModel(m); return 0; }

这个程序把模型路径、初始角度、初始角速度都通过命令行传入。配合 shell 的 for 循环,可以从0.1扫到1.5弧度,一次输出多条结果:

for th in 0.1 0.5 1.0 1.57 3.0; do ./build/run_dyn pendulum.xml "$th" 0.0 done

qpos只传一个值是因为模型只有一个自由度。如果模型有多个自由度,程序里就要按nq顺序读参数,或者读一个 JSON 文件来传状态。命令行方式适合临时实验,文件方式适合正式实验记录。

5.2 把 mj_forward 接成时间步进循环:得到一条完整轨迹

单步mj_forward算的是某个冻结状态下的瞬时加速度,而实际动力学计算通常需要随时间推进。MuJoCo 提供mj_step,它在内部做半隐式欧拉积分,每一步都会更新qpos、qvel和仿真时间。

// 在 mj_makeData 之后 double dt = m->opt.timestep; // 步长,单位秒 for (int i = 0; i < 2000; ++i) { mj_step(m, d); std::printf("%.6f %.6f %.6f\n", i * dt, d->qpos[0], d->qvel[0]); }

这里的m->opt.timestep是模型文件里配置的时间步长,MuJoCo 默认通常是0.002秒,也就是 2 毫秒。如果想改积分步长,在 XML 的<option>标签里加timestep="0.001"即可。

注意不要在一个循环里既调mj_forward又调mj_step。mj_step内部已经做了“计算加速度 + 积分更新”两步,如果混用,状态会被重复推进,数据就不连续了。想观察每一步加速度,可以在调用mj_step之前打印d->qacc,或者直接在一个稳态构型下用mj_forward单独算。

5.3 与解析解对照的三种验证方法

做完时间步进之后,你需要一个判断标准,确认仿真结果没有偏离物理规律。这里给出三种验证手段,按成本从低到高排列:

验证方式构造方法通过标准
单步瞬态对比固定qpos、qvel,调用mj_forward,与解析公式比较数值差小于 2%
能量守恒关闭摩擦和阻尼,记录总能量随时间变化总能量波动小于初值的 0.5%
周期对比单摆从同一初始角度释放,记录角速度过零时间两次周期差异小于 1%

能量守恒是最有效的大坑探测器。如果在关闭阻尼后机械能明显衰减,说明你无意中开了摩擦,或者执行器仍在输出力矩。检查d->ctrl和 XML 里是否没有damping属性。周期对比则能验证长时间积分是否稳定——如果步长过大,周期会系统性偏移。

6. 顺手把轨迹落盘成 CSV:一套可回放的动力学实验习惯

我现在的项目习惯是:任何动力学计算程序,都先把结果写成 CSV,再考虑打印屏幕。原因很简单,屏幕输出是易失的,改一个参数重新编译就没了;CSV 可以做曲线图、可以和其他工具比对、也可以交给同事复现。

在时间步进循环里加上文件输出只需要几行:

#include <cstdio> FILE* fp = std::fopen("trajectory.csv", "w"); std::fprintf(fp, "time,qpos,qvel,qacc\n"); double dt = m->opt.timestep; for (int i = 0; i < 2000; ++i) { std::printf("time=%.6f\n", m->data->time); double acc_before_step = d->qacc[0]; std::fprintf(fp, "%.6f,%.6f,%.6f,%.6f\n", i * dt, d->qpos[0], d->qvel[0], acc_before_step); mj_step(m, d); } std::fclose(fp);

注意我在mj_step之前读qacc,因为步进之后qacc会被下一次前向动力学覆盖。拿到 CSV 后,用 Python 读取并画相位图qvel对qpos,单摆的轨迹会呈现闭合环;如果环逐渐缩小,说明有能量损失。

这个工作流的价值不在代码量,而在于“可回放”。任何一次实验,只要保留模型 XML 和 CSV,别人就能精确复现你的结果。我希望你在自己的项目里也保留这个习惯,哪怕只是临时调试。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询