Integrating Custom Sensors in Gazebo with ROS 2: A Practical Guide
Learn how to add realistic sensor data to Gazebo simulations by writing a custom sensor plugin that publishes ROS 2 topics, with a worked example, performance notes, and verification steps.
31 Aug 2026, 07:05 UTC

The Problem: Simulating Realistic Sensor Data
When developing robot software, you often need sensor data that mimics real‑world noise, field‑of‑view limits, and update rates. Purely synthetic topics from a ROS 2 node can miss the subtle coupling between sensor physics and robot motion, leading to surprises when the code runs on hardware.
Thesis: Use Gazebo's Sensor Plugin API with ROS 2 to inject custom sensor streams
Gazebo already provides a plugin interface for defining new sensor models. By implementing a C++ class that inherits from gazebo::sensors::Sensor and overriding its OnUpdate method, you can generate any signal you like and publish it as a ROS 2 topic using rclcpp. This keeps the simulation loop inside Gazebo while delivering data directly to your robot‑control stack.
How Gazebo Sensor Plugins Work
A sensor plugin is a shared library that Gazebo loads at runtime from an SDF world file. The plugin receives a simulation step callback (OnUpdate) where you can read the model’s pose, compute a measurement, and push the result onto a ROS 2 publisher. Because the plugin runs inside Gazebo’s main thread, it inherits the same real‑time factor as the physics engine.
Integrating with ROS 2
Inside OnUpdate you create a rclcpp::Node (or reuse a node created in Load) and publish to a topic of your choice, e.g., /custom_lidar/scan. The ROS 2 middleware (DDS) transports the message to any subscribing node, whether it lives in the same process or elsewhere.
Worked Example: Minimal Lidar Plugin
Below is a minimal lidar‑style plugin that publishes a fake sensor_msgs/msg/LaserScan at 10 Hz. Replace placeholders with your own names.
#include
#include
#include
#include
#include
namespace gazebo {
class CustomLidar : public Sensor {
public:
CustomLidar() : Sensor() {}
void Load(sensors::SensorPtr _parent, sdf::ElementPtr _sdf) override {
Sensor::Load(_parent, _sdf);
// ROS 2 node initialization
this->rosNode_ = rclcpp::Node::make_shared("custom_lidar");
this->pub_ = this->rosNode_->create_publisher(
"/custom_lidar/scan", 10);
// timer to control publish rate
this->timer_ = this->rosNode_->create_wall_timer(
std::chrono::milliseconds(100),
std::bind(&CustomLidar::OnTimer, this));
}
private:
void OnUpdate() override {
// This is called every simulation step; we do minimal work here.
// Actual publishing happens in the timer callback to keep OnUpdate light.
}
void OnTimer() {
auto scan = sensor_msgs::msg::LaserScan();
scan.header.stamp = this->rosNode_->now();
scan.header.frame_id = "lidar_link";
scan.angle_min = -1.57;
scan.angle_max = 1.57;
scan.angle_increment = 0.01;
scan.range_min = 0.1;
scan.range_max = 30.0;
scan.ranges.assign(314, 5.0); // fake constant range
this->pub_->publish(scan);
}
rclcpp::Node::SharedPtr rosNode_;
rclcpp::Publisher::SharedPtr pub_;
rclcpp::TimerBase::SharedPtr timer_;
};
// Register the plugin with Gazebo
GZ_REGISTER_SENSOR_PLUGIN(CustomLidar)
}
Build the plugin (assuming a ROS 2 workspace):
- Create a package
custom_lidar_pluginwithCMakeLists.txtthat links againstgazebo,gazebo_sensors, andrclcpp. - Run
colcon build --packages-select custom_lidar_pluginin the workspace root (requires read/write permission on the workspace). - Source the workspace (
source install/setup.bash) before launching Gazebo.
Add the plugin to an SDF world:
<sdf version='1.6'>
<world name='default'>
<!-- your models -->
<plugin name='custom_lidar' filename='libcustom_lidar_plugin.so'>
</plugin>
</world>
</sdf>
When you start Gazebo (gazebo worlds/custom_world.sdf), you should see no errors in the console. To verify the ROS 2 topic:
- In a separate terminal, run
ros2 topic echo /custom_lidar/scan. - You should receive
LaserScanmessages at approximately 10 Hz. - Check the Gazebo console for any plugin‑related warnings; absence of warnings indicates successful load.
Trade‑off: Performance and ABI Compatibility
The research brief notes that a well‑written custom lidar plugin adds less than 2 ms per simulation step on a typical CPU, which is acceptable for real‑time factors near 1.0. However, two practical limits apply:
- ABI mismatch: The plugin must be compiled against the exact Gazebo version (and compiler) used at runtime. A different patch level can cause crashes or undefined behavior.
- Computation load: Heavy work inside
OnUpdatedirectly impacts the simulation step timing, potentially causing missed real‑time factors or physics instability. Keep the callback light and offload expensive processing to timers or separate threads as shown.
To check performance, run Gazebo with verbose profiling:
gazebo --verbose worlds/custom_world.sdf
Compare the average step time reported in the console with and without the plugin loaded. A difference under a few milliseconds indicates the plugin stays within the budget.
Actionable Closing
If you need sensor data that reflects the true geometric and temporal coupling of your robot’s motion, building a Gazebo sensor plugin and publishing via ROS 2 is a straightforward, low‑overhead approach. Start with the minimal lidar example above, verify the topic output, then replace the fake data with your realistic model (e.g., ray‑based range computation, camera image synthesis, or IMU noise). Keep the OnUpdate method lightweight, watch ABI compatibility, and use the profiling step to ensure your simulation stays responsive.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.