Gazebo Plugin or ROS 2 Node? Where to Put Your Robot Control Logic
Choosing between Gazebo in-process plugins and external ROS 2 nodes for robot control. Covers version differences (Classic vs Sim), a working plugin example, and a practical rule: prototype externally, move in only when latency or physics access demands it.
10 Oct 2025, 06:27 UTC

The Core Trade-off: Latency vs Isolation
Every Gazebo user eventually asks: should this control loop live inside the simulator as a model plugin, or outside as a ROS 2 node? The answer shapes debugging, testing, and whether a segmentation fault kills your entire simulation session.
In-process model plugins give you the lowest latency and direct access to physics::ModelPtr, physics::JointPtr, and the world update event. You can read joint positions and apply forces every simulation step without crossing a process boundary. The cost: your code compiles against Gazebo headers, links against Gazebo libraries, and runs inside the gzserver (Classic) or gz sim (Sim) process. An unhandled exception or segfault terminates the simulator.
External ROS 2 nodes communicate over DDS topics. They're easier to version-pin, unit-test with ament_cmake, and reuse across simulators (Gazebo Classic, Gazebo Sim, Webots, Isaac Sim). The penalty: transport latency, serialization overhead, and no direct pointer access to internal physics state.
Version Matters: Classic vs Sim
Gazebo Classic (Gazebo 11, the last release of the gazebo package) uses the gazebo:: C++ namespace and gazebo_ros_pkgs for ROS 2 integration. Gazebo Sim (formerly Ignition Gazebo, now gz-sim 6+) uses the gz:: namespace and ros_gz bridges. Do not mix them. Headers, link libraries, and message types are not interchangeable. A plugin compiled for Classic will not load in Sim, and vice versa.
Before writing any plugin, verify your simulator version:
# Gazebo Classic
gazebo --version
# Gazebo Sim
gz sim --version
Install the matching development package (libgazebo11-dev or gz-sim6-dev on Ubuntu) so your plugin links against the same headers the simulator loaded.
Worked Example: A Minimal Model Plugin
Here's a complete, compilable model plugin that applies a sinusoidal torque to a single joint every simulation step. It targets Gazebo Sim (gz-sim 6+); for Classic, replace gz::sim:: with gazebo:: and adjust the event connection.
C++ Plugin Source (sinusoidal_torque.cc)
#include <gz/sim/Model.hh>
#include <gz/sim/Util.hh>
#include <gz/sim/components/Joint.hh>
#include <gz/sim/components/Name.hh>
#include <gz/plugin/Register.hh>
#include <cmath>
namespace my_plugins {
class SinusoidalTorque : public gz::sim::System,
public gz::sim::ISystemConfigure,
public gz::sim::ISystemPreUpdate {
public:
void Configure(const gz::sim::Entity &_entity,
const std::shared_ptr &_sdf,
gz::sim::EntityComponentManager &_ecm,
gz::sim::EventManager &/*_eventMgr*/) override {
model_ = gz::sim::Model(_entity);
if (!model_.Valid(_ecm)) {
gzerr << "Plugin attached to non-model entity\n";
return;
}
jointName_ = _sdf->Get("joint", "").first;
amplitude_ = _sdf->Get("amplitude", 1.0).first;
frequency_ = _sdf->Get("frequency", 1.0).first;
}
void PreUpdate(const gz::sim::UpdateInfo &_info,
gz::sim::EntityComponentManager &_ecm) override {
if (jointName_.empty()) return;
auto jointEnt = model_.JointByName(_ecm, jointName_);
if (!jointEnt) return;
double t = _info.simTime.count() * 1e-9; // nanoseconds to seconds
double torque = amplitude_ * std::sin(2 * M_PI * frequency_ * t);
_ecm.SetComponentData(
jointEnt, {torque});
}
private:
gz::sim::Model model_;
std::string jointName_;
double amplitude_ = 1.0;
double frequency_ = 1.0;
};
}
GZ_ADD_PLUGIN(my_plugins::SinusoidalTorque,
gz::sim::System,
my_plugins::SinusoidalTorque::ISystemConfigure,
my_plugins::SinusoidalTorque::ISystemPreUpdate)
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(sinusoidal_torque_plugin LANGUAGES CXX)
find_package(gz-sim6 REQUIRED COMPONENTS
gz-sim6-core
gz-sim6-plugins
)
add_library(sinusoidal_torque SHARED sinusoidal_torque.cc)
target_link_libraries(sinusoidal_torque
gz-sim6::gz-sim6-core
gz-sim6::gz-sim6-plugins
)
target_include_directories(sinusoidal_torque PUBLIC
$
)
install(TARGETS sinusoidal_torque
LIBRARY DESTINATION lib
)
SDF Model Snippet
<model name="pendulum">
<plugin name="sinusoidal_torque" filename="libsinusoidal_torque.so">
<joint>hinge</joint>
<amplitude>0.5</amplitude>
<frequency>2.0</frequency>
</plugin>
<link name="base"/>
<link name="arm"/>
<joint name="hinge" type="revolute">
<parent>base</parent>
<child>arm</child>
<axis>
<xyz>0 0 1</xyz>
</axis>
</joint>
</model>
Build and Run
mkdir build && cd build
cmake ..
make
# Set plugin path so Gazebo finds the .so
export GAZEBO_PLUGIN_PATH=${PWD}:${GAZEBO_PLUGIN_PATH}
export LD_LIBRARY_PATH=${PWD}:${LD_LIBRARY_PATH}
# Launch a world containing the model above
gz sim -v 4 my_world.sdf
Run this on the same machine where you compiled the plugin. The GAZEBO_PLUGIN_PATH and LD_LIBRARY_PATH must include the build directory; otherwise Gazebo prints "Failed to load plugin" and the model behaves passively.
When to Cross the Boundary
Move logic into a plugin only when you hit a concrete limitation of the external node:
- Control frequency > 500 Hz with sub-millisecond jitter requirements. DDS transport adds variable latency; the world update event runs synchronously with the physics step.
- Direct physics mutation — setting joint forces, modifying link masses, or reading contact normals without a round-trip through
ros_gz_bridge. - Sensor simulation hooks — custom noise models or ground-truth publishers that need access to
gz::sim::Sensorinternals.
Everything else — trajectory planning, state estimation, higher-level state machines, UI — stays in a ROS 2 node. You gain:
- Independent debugging with gdb or rr without attaching to the simulator.
- Unit tests that run in CI without launching Gazebo.
- The ability to swap simulators (or run on hardware) by changing only the bridge configuration.
Limitations and Verification
Simulation determinism is not guaranteed. Results depend on CPU load, solver iterations (physics.solver.iters in SDF), and step size. Plugin timing should not be treated as hard real time. A plugin that works on a desktop may drift on a loaded CI runner.
Verify your plugin loads cleanly: watch the console for "Loaded plugin" messages and confirm the model moves as expected. Mirror the same behavior with an external ROS 2 node using ros2 topic echo /model/pendulum/joint/hinge/cmd_force and compare latency with ros2 topic hz. Only migrate the hot path once you've measured the gap.
If you need to roll back, remove the <plugin> tag from SDF and restart Gazebo — no state persists in the simulator process.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.