Gazebo ModelPlugin Architecture: Injecting Custom C++ Logic into Simulated Robots
ModelPlugins let you run custom C++ logic inside Gazebo's simulation loop. This guide covers the Load/Init/Update lifecycle, a differential-drive plugin example, and trade‑offs versus ROS 2 control — including ABI version locking, joint authority conflicts, and Transport topic mismatches.
18 Oct 2026, 20:47 UTC

The Problem and the Takeaway
You need custom C++ logic inside a Gazebo simulation — perhaps a specialized controller, sensor noise model, or joint-level force application that ROS 2 controllers cannot express. A ModelPlugin is a shared library loaded by Gazebo at world start that receives a pointer to the physics::Model and an SDF element with your parameters. It runs in Gazebo's main simulation thread, giving you per-step access to joints, links, and sensors via the Update() callback connected to event::Events::ConnectWorldUpdateBegin.
Useful takeaway: ModelPlugins are the right tool when you need deterministic, in-process access to the physics engine at every simulation step. They are the wrong tool for heavy computation (blocks the physics step) or when a ROS 2 controller via ros_gz_bridge can express the same logic — choose one authority per joint to avoid solver fights.
Lifecycle Callbacks: Load, Init, Update
Every ModelPlugin implements up to three entry points:
Load(physics::ModelPtr model, sdf::ElementPtr sdf)— one-time setup: cache joint/link pointers, parse SDF parameters, create Transport subscribers/publishers.Init()— optional post-load initialization after all plugins have loaded; useful for cross-plugin dependencies.Update()— your per-step logic, connected toWorldUpdateBegininsideLoad(). Fires at every physics iteration (default 1 kHz).
Unfamiliar term: physics::ModelPtr is a shared pointer to the simulated model, providing access to joints (model->GetJoint(\"name\")), links, and sensors (model->GetSensor(\"name\")).
Worked Example: Differential-Drive Plugin
Below is a minimal, complete plugin that reads left/right wheel joint names from SDF, subscribes to /cmd_vel (gz::msgs::Twist), and applies wheel forces in Update() using a simple kinematic model.
1. Plugin Source (diff_drive_plugin.cc)
#include <gz/sim/Model.hh>\n#include <gz/sim/Util.hh>\n#include <gz/plugin/Register.hh>\n#include <gz/transport/Node.hh>\n#include <gz/msgs/Twist.pb.h>\n#include <gz/common/PID.hh>\n\nnamespace my_plugins\n{\nclass DiffDrivePlugin : public gz::sim::System,\n public gz::sim::ISystemConfigure,\n public gz::sim::ISystemPreUpdate\n{\npublic:\n void Configure(const gz::sim::Entity &_entity,\n const std::shared_ptr &_sdf,\n gz::sim::EntityComponentManager &_ecm,\n gz::sim::EventManager &/*_eventMgr*/) override\n {\n this->model = gz::sim::Model(_entity);\n if (!this->model.Valid(_ecm)) {\n gzerr << \"DiffDrivePlugin: invalid model entity\\n\";\n return;\n }\n\n // Parse SDF parameters with guards\n if (!_sdf->HasElement(\"left_joint\") || !_sdf->HasElement(\"right_joint\")) {\n gzerr << \"DiffDrivePlugin: missing left_joint or right_joint in SDF\\n\";\n return;\n }\n this->leftJointName = _sdf->Get<std::string>(\"left_joint\");\n this->rightJointName = _sdf->Get<std::string>(\"right_joint\");\n this->wheelSeparation = _sdf->Get<double>(\"wheel_separation\", 0.5).first;\n this->wheelRadius = _sdf->Get<double>(\"wheel_radius\", 0.1).first;\n\n // Cache joint pointers\n this->leftJoint = this->model.JointByName(_ecm, this->leftJointName);\n this->rightJoint = this->model.JointByName(_ecm, this->rightJointName);\n if (!this->leftJoint || !this->rightJoint) {\n gzerr << \"DiffDrivePlugin: joints not found\\n\";\n return;\n }\n\n // Transport subscriber for /cmd_vel\n this->node.Subscribe(\"/cmd_vel\", &DiffDrivePlugin::OnCmdVel, this);\n }\n\n void PreUpdate(const gz::sim::UpdateInfo &_info,\n gz::sim::EntityComponentManager &_ecm) override\n {\n if (_info.paused) return;\n\n // Throttle to 30 Hz instead of 1 kHz physics rate\n static gz::common::Time lastUpdate = 0;\n gz::common::Time now = _info.simTime;\n if ((now - lastUpdate).Double() < (1.0 / 30.0)) return;\n lastUpdate = now;\n\n // Simple differential drive kinematics\n double v = this->cmdVelLinear;\n double w = this->cmdVelAngular;\n double leftVel = (v - w * this->wheelSeparation / 2.0) / this->wheelRadius;\n double rightVel = (v + w * this->wheelSeparation / 2.0) / this->wheelRadius;\n\n // Apply velocity commands (SetVelocity fights physics less than SetForce for wheels)\n this->leftJoint->SetVelocity(0, leftVel);\n this->rightJoint->SetVelocity(0, rightVel);\n }\n\nprivate:\n void OnCmdVel(const gz::msgs::Twist &_msg)\n {\n this->cmdVelLinear = _msg.linear().x();\n this->cmdVelAngular = _msg.angular().z();\n }\n\n gz::sim::Model model;\n gz::transport::Node node;\n std::string leftJointName, rightJointName;\n double wheelSeparation = 0.5;\n double wheelRadius = 0.1;\n double cmdVelLinear = 0.0;\n double cmdVelAngular = 0.0;\n gz::sim::Joint leftJoint = gz::sim::kNullEntity;\n gz::sim::Joint rightJoint = gz::sim::kNullEntity;\n};\n}\n\nGZ_ADD_PLUGIN(my_plugins::DiffDrivePlugin,\n gz::sim::System,\n gz::sim::ISystemConfigure,\n gz::sim::ISystemPreUpdate)\nGZ_ADD_PLUGIN_ALIAS(my_plugins::DiffDrivePlugin,\n \"my_plugins::DiffDrivePlugin\")2. CMakeLists.txt
cmake_minimum_required(VERSION 3.16)\nproject(diff_drive_plugin LANGUAGES CXX)\n\nfind_package(gz-sim8 REQUIRED COMPONENTS\n gz-sim\n gz-transport\n gz-msgs\n gz-plugin\n gz-common\n)\n\nadd_library(diff_drive_plugin SHARED diff_drive_plugin.cc)\ntarget_link_libraries(diff_drive_plugin\n ${gz-sim8_LIBRARIES}\n gz-transport::gz-transport\n gz-msgs::gz-msgs\n gz-plugin::gz-plugin\n gz-common::gz-common\n)\ntarget_compile_features(diff_drive_plugin PRIVATE cxx_std_17)\nset_target_properties(diff_drive_plugin PROPERTIES\n PREFIX \"\"\n SUFFIX \"${CMAKE_SHARED_MODULE_SUFFIX}\"\n)Where to run: In your ROS 2 / Gazebo workspace, e.g., ~/gz_ws/src/my_plugins. Permissions: Regular user; no sudo needed if you set GZ_SIM_SYSTEM_PLUGIN_PATH.
3. Model SDF Snippet
<model name=\"my_robot\">\n <plugin name=\"diff_drive\" filename=\"libdiff_drive_plugin.so\">\n <left_joint>left_wheel_joint</left_joint>\n <right_joint>right_wheel_joint</right_joint>\n <wheel_separation>0.5</wheel_separation>\n <wheel_radius>0.1</wheel_radius>\n </plugin>\n ... links and joints ...\n</model>The filename is the compiled shared library name without the lib prefix on Linux. Nested tags become children of the SDF element passed to Configure() (Gazebo Harmonic uses ISystemConfigure instead of the classic Load()).
Building and Loading
- Build:
mkdir build && cd build && cmake .. && make -j4(run in plugin source directory). - Export plugin path:
export GZ_SIM_SYSTEM_PLUGIN_PATH=$PWD/build:${GZ_SIM_SYSTEM_PLUGIN_PATH}(run in the same shell before launching Gazebo). - Launch simulation:
gz sim -v 4 my_world.sdf(run from world SDF directory).-v 4enables verbose console output wheregzlog/gzerrfrom your plugin will appear.
Expected check: You should see your plugin's log lines (if any) in the console. If the plugin fails to load, Gazebo prints Failed to load plugin with the path it searched.
Verifying the Plugin Works
- Publish a command: In a second terminal (same shell with
GZ_SIM_SYSTEM_PLUGIN_PATHset):gz topic -t /cmd_vel -m gz.msgs.Twist -p 'linear: {x: 0.5}' - Observe wheel velocities:
gz topic -e /model/my_robot/joint/left_wheel_joint/velocity— you should see values near5.0rad/s (0.5 m/s / 0.1 m radius). - Check update rate: Add a log line in
PreUpdate()printing_info.simTime.Double()and confirm the delta matches your throttle (≈0.033 s for 30 Hz) or the physics step (0.001 s if unthrottled).
Limits and Common Mistakes
ABI Version Locking
A plugin compiled against gz-sim8 (Gazebo Garden) will not load in gz-sim9 (Gazebo Harmonic) without recompilation. The CMake find_package(gz-sim8 REQUIRED) must match the runtime major version. In CI, build and test against each supported major version in separate jobs.
SDF Parsing Without Guards
Calling sdf->GetElement(\"param\")->Get<double>() on a missing element dereferences a null pointer. Always check HasElement() first or use the two-argument Get<T>(\"param\", default) overload which returns std::pair<T, bool>.
Update() Runs at Physics Rate (1 kHz Default)
If your logic only needs 30 Hz, throttle inside PreUpdate() (or classic Update()) using common::Time as shown in the example. Unthrottled heavy work (e.g., image processing, path planning) will slow the entire simulation.
Joint Authority Conflicts
If you also drive the same joint from a ROS 2 controller via ros_gz_bridge (which writes /model/.../joint/.../cmd_force or cmd_vel topics), the physics solver receives conflicting commands each step. Result: jitter, instability, or energy injection. Pick one authority per joint: either the ModelPlugin or the ROS 2 controller, not both.
Transport Topic and Type Mismatches
Gazebo Transport uses ZeroMQ + protobuf. The topic name (/cmd_vel) and message type (gz.msgs.Twist) must match exactly between publisher and subscriber. Mismatches silently drop messages — no error is logged. Verify with gz topic -l and gz topic -i /cmd_vel.
Plugin Discovery Path
Plugins installed to system paths (/usr/lib/gz/sim8/plugins) require ldconfig after install. For development, GZ_SIM_SYSTEM_PLUGIN_PATH pointing to your build directory avoids sudo and caches. If Gazebo says plugin not found, check echo $GZ_SIM_SYSTEM_PLUGIN_PATH and that the .so exists there.
When to Choose ModelPlugin vs. ROS 2 Control
| Criterion | ModelPlugin | ROS 2 Controller + ros_gz_bridge |
|---|---|---|
| Deterministic per-step access | Yes (in-process) | No (topic latency, ~1-2 ms) |
| Custom sensor noise / modification | Direct sensor API | Not possible |
| Heavy computation (ML, planning) | Blocks physics — avoid | Offloaded to separate process |
| Standard joint control (PID, diff drive) | Reinventing wheel | Use gz_ros2_control plugins |
| Language | C++ only | C++ or Python controllers |
| ABI stability | Tied to Gazebo major version | Bridge handles version gaps |
Rule of thumb: if the behavior exists in gz_ros2_control (diff drive, arm controllers, gripper), use ROS 2 control. Reach for a ModelPlugin only when you need in-process physics access that the bridge cannot provide.
Practical Verification Checklist
- Build plugin against target
gz-simmajor version; confirmlibdiff_drive_plugin.soexists in build directory. - Set
GZ_SIM_SYSTEM_PLUGIN_PATHand launchgz sim -v 4 world.sdf; verify no load errors in console. - Publish
/cmd_velviagz topic; confirm wheel joint velocities respond viagz topic -e. - Log
_info.simTimeinPreUpdate(); confirm throttle or physics-step cadence. - Cross-compile same source with
find_package(gz-sim9 REQUIRED)and test load — validates ABI handling. - Run headless in CI:
gz sim -s -r world.sdfwith a script that asserts robot pose after N seconds.
These steps catch regressions in control logic, plugin loading, and version compatibility without manual GUI inspection.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.