DocsDevelop
Gazebo Assets
Add a world, an object and a drone model to the Gazebo simulation from your own project, and a new sensor to as2_gazebo_assets.
as2_gazebo_assets builds the Gazebo simulation from a
configuration file, and finds every world and model in it by name. Worlds, objects and drone models
can live in your own project, so adding them doesn't change Aerostack2. Sensors are the exception,
since the package keeps the list of sensor types and the topics each one bridges.
This guide builds a world with a landing pad and a slower variant of the stock quadrotor, all in a project folder, and flies the drone onto the pad. Then it adds a new camera to the package itself.
Before you start
- The Gazebo simulation running. Gazebo shows how to launch it and fly a drone in it.
- Some SDF. The models are SDFormat files, and the world and drone files are Jinja templates around SDF.
How assets are found
Each entry in the configuration file names an asset, and the launch looks for its file first in the
package's worlds or models folder, then in every folder on GZ_SIM_RESOURCE_PATH.
| In the configuration | File it looks for |
|---|---|
world_name | <world_name>.sdf.jinja, or else <world_name>.sdf, directly in a folder |
A drone's model_type | <model_type>/<model_type>.sdf.jinja |
An object's model_type | <model_type>/<model_type>.sdf |
A payload's model_type | Nothing. The drone's template draws it, and the type must be one the package knows |
Worlds and drones are templates. The launch renders them into /tmp/<world_name>.sdf and
/tmp/<model_type>_<index>.sdf, then starts Gazebo with the world and spawns each drone and object
into it. A world template receives the origin from the configuration. A drone template receives
its namespace, its payload, its battery and which controller to enable.
Drone types are checked against a fixed list, so a new one also goes in AS2_EXTRA_DRONE_MODELS, a
list of names separated by colons. Payload types are fixed in the package and have no such variable.
Add assets from your project
Create the folders
mkdir -p my_project/worlds my_project/models/landing_pad my_project/models/slow_quadrotor my_project/config
cd my_projectBy the end of the guide the project holds these files:
- my_project/
- worlds/
- pad_world.sdf.jinjaThe world
- models/
- landing_pad/
- model.config
- landing_pad.sdfAn object that publishes its pose
- slow_quadrotor/
- slow_quadrotor.sdf.jinjaA drone model
- config/
- world.yamlWhat the simulation spawns
- config.yamlThe drone's stack
- pid_speed_controller.yamlThe controller's gains
Make the world
Start from the package's empty world, which already has what Aerostack2 needs:
cp $(ros2 pkg prefix as2_gazebo_assets)/share/as2_gazebo_assets/worlds/empty.sdf.jinja \
worlds/pad_world.sdf.jinjaThen rename the world inside it, so its name matches the file:
<world name="pad_world">Every bridge builds its Gazebo topic from world_name, like /world/pad_world/model/..., so a
world whose name differs from its file spawns nothing.
The template keeps four systems that the drones rely on. Physics moves them, user commands serve
the service that spawns them, the scene broadcaster feeds the Gazebo window, and the sensors system
renders cameras and lidars. Its spherical_coordinates block only appears when the configuration
has an origin, which GPS needs.
Fixed scenery goes in the world too, as an <include> of a model like the ground_plane it already
has.
Add an object
An object is a model that the configuration spawns and that can bridge topics to ROS 2. The landing pad publishes its pose:
<?xml version="1.0"?>
<sdf version="1.9">
<model name="landing_pad">
<link name="pad">
<pose>0 0 0.025 0 0 0</pose>
<inertial>
<mass>5.0</mass>
</inertial>
<collision name="collision">
<geometry>
<cylinder>
<radius>0.5</radius>
<length>0.05</length>
</cylinder>
</geometry>
</collision>
<visual name="visual">
<geometry>
<cylinder>
<radius>0.5</radius>
<length>0.05</length>
</cylinder>
</geometry>
<material>
<diffuse>1.0 0.5 0.0 1</diffuse>
</material>
</visual>
</link>
<!-- Publishes the pad's pose on /model/<name>/pose, for the pose bridge -->
<plugin
filename="ignition-gazebo-pose-publisher-system"
name="ignition::gazebo::systems::PosePublisher">
<publish_model_pose>true</publish_model_pose>
<publish_link_pose>false</publish_link_pose>
<publish_nested_model_pose>true</publish_nested_model_pose>
<use_pose_vector_msg>false</use_pose_vector_msg>
<update_frequency>10</update_frequency>
</plugin>
</model>
</sdf><?xml version="1.0"?>
<model>
<name>Landing pad</name>
<version>1.0</version>
<sdf version="1.9">landing_pad.sdf</sdf>
<description>A round landing pad that publishes its pose.</description>
</model>An object's object_bridges list picks the topics it bridges.
| Bridge | Topic | Needs in the model |
|---|---|---|
pose | /<model_name>/<model_name>/pose, as geometry_msgs/PoseStamped | A pose publisher that sends single poses |
gps | /<model_name>/sensor_measurements/gps, as sensor_msgs/NavSatFix | A nested model named gps with a navsat sensor, such as an include of model://gps |
An object's joints list also bridges a velocity command for each joint, on
/gz/<model_name>/joint/<joint>/cmd_vel, and tf_broadcaster: true publishes its pose on TF
relative to earth.
Add a drone model
A drone model is a template. Copy the stock quadrotor's:
cp $(ros2 pkg prefix as2_gazebo_assets)/share/as2_gazebo_assets/models/quadrotor_base/quadrotor_base.sdf.jinja \
models/slow_quadrotor/slow_quadrotor.sdf.jinjaThis variant only flies slower. In its velocity controller, lower the top speed from 5 to 1 m/s:
<maximumLinearVelocity>1 1 1</maximumLinearVelocity>Each part of the template ties the model to Aerostack2:
<model name='{{ namespace }}'>names the model after the drone, which is also its namespace.- The
<include merge="true">ofmodel://quadrotor_basebrings in the body, rotors and meshes, so the template only adds plugins. A model of your own replaces that file. Its body link has to bebase_link, and each rotor needs a joint named after it with_jointat the end. - The pose publisher sends the drone's links and sensors to TF.
- The motor models and the velocity controller listen under
model/{{ namespace }}, where the bridges sendcmd_veland the arming command. The velocity controller only appears when the drone'senable_velocity_controlis on, which is the default, and the acro controller whenenable_acro_controlis. - The
for sensor in sensorsloop draws the payload. Sensors with a template of their own, like the cameras, are included from it. The rest come in asmodel://<type>with a fixed joint tobase_link.
Drone models outside the package also need their name in AS2_EXTRA_DRONE_MODELS, which a later
step sets.
Sensor templates must sit next to the drone
The drone's template includes sensor templates from its own models folder. For a drone in your
project, the stock cameras, gimbals and suction gripper aren't found there, and the drone fails to
render. Copy the template of each one you use, like hd_camera/hd_camera.sdf.jinja, into your
models folder. GPS, lidars, air pressure and the magnetometer need nothing, since they come in
as model://.
Describe the simulation
The configuration file puts the drone, its payload and the pad in the new world:
world_name: "pad_world"
origin:
latitude: 40.4405287
longitude: -3.6898277
altitude: 100.0
drones:
- model_type: "slow_quadrotor"
model_name: "drone0"
xyz: [0.0, 0.0, 0.3]
payload:
- model_type: "gps"
model_name: "gps"
objects:
- model_type: "landing_pad"
model_name: "pad"
xyz: [3.0, 0.0, 0.0]
object_bridges: ["pose"]The same file in JSON works too. Gazebo lists every field and stock model.
Point Gazebo at the folders
In every terminal that launches the simulation or the platform, add both folders to Gazebo's path and register the drone model:
export GZ_SIM_RESOURCE_PATH=$GZ_SIM_RESOURCE_PATH:$PWD/worlds:$PWD/models
export AS2_EXTRA_DRONE_MODELS=slow_quadrotorSourcing Aerostack2, or pixi run and pixi shell, put the package's own folders at the front, so
the stock assets keep their names.
Launch it
Start the simulation, then the platform, which creates the drone's bridges:
ros2 launch as2_gazebo_assets launch_simulation.py \
simulation_config_file:=config/world.yaml
ros2 launch as2_platform_gazebo platform_gazebo_launch.py \
namespace:=drone0 simulation_config_file:=config/world.yamlThe pad's pose arrives on ROS 2, in the world's frame:
ros2 topic echo --once /pad/pad/poseheader:
stamp:
sec: 83
nanosec: 100000000
frame_id: pad_world
pose:
position:
x: 3.0
y: -9.582009233869264e-20
z: -8.178440423674271e-07The drone has its GPS on /drone0/sensor_measurements/gps, next to the IMU and the ground truth that
every Gazebo drone publishes.
Land on the pad
Start the rest of the stack with the ground truth estimator. Gazebo roots each drone's TF tree at the bare namespace, so the base frame is an empty name:
/**:
ros__parameters:
use_sim_time: true
state_estimator:
ros__parameters:
base_frame_id: ""
plugin_name: "ground_truth"
ground_truth:
twist_sub_topic: "ground_truth/twist"
TakeoffBehavior:
ros__parameters:
takeoff_plugin_name: "takeoff_plugin_position"
LandBehavior:
ros__parameters:
land_plugin_name: "land_plugin_speed"
GoToBehavior:
ros__parameters:
go_to_plugin_name: "go_to_plugin_position"
FollowPathBehavior:
ros__parameters:
follow_path_plugin_name: "follow_path_plugin_position"ros2 launch as2_state_estimator state_estimator_launch.py \
namespace:=drone0 use_sim_time:=true config_file:=config/config.yaml
ros2 launch as2_motion_controller controller_launch.py \
namespace:=drone0 use_sim_time:=true plugin_name:=pid_speed_controller \
plugin_config_file:=config/pid_speed_controller.yaml
ros2 launch as2_behaviors_motion motion_behaviors_launch.py \
namespace:=drone0 config_file:=config/config.yamlThe controller's gains are the ones from the motion controller plugins. Then fly to the pad and land on it:
import rclpy
from as2_python_api.drone_interface import DroneInterface
rclpy.init()
drone = DroneInterface(drone_id='drone0', use_sim_time=True)
drone.arm()
drone.offboard()
print('takeoff', drone.takeoff(height=1.0, speed=0.5))
print('go_to', drone.go_to(3.0, 0.0, 1.0, speed=2.0))
print('land', drone.land(speed=0.3))
print('at', [round(v, 2) for v in drone.position])
drone.manual()
drone.shutdown()
rclpy.shutdown()takeoff True
go_to True
land True
at [3.06, -0.01, 0.05]The drone ends 5 cm up, on top of the pad. On the way it was asked for 2 m/s and stayed under 1 m/s, where the stock quadrotor reaches about 1.5 m/s over the same 3 m.
A template error doesn't stop the launch
When a drone's template fails to render, the launch prints the Jinja error and carries on. Gazebo
then spawns whatever is left at /tmp/<model_type>_<index>.sdf from an earlier run, or nothing.
Check the log for the error after changing a template.
Add a sensor to as2_gazebo_assets
A sensor of a new kind needs changes in the package, because the package decides which payload
types exist and which topics each one bridges. This part adds wide_camera, a camera with a
120° lens, in a source checkout.
Write the sensor's template
Cameras are templates, so the same file works fixed to the drone or on a gimbal. Copy
models/hd_camera to models/wide_camera and set the lens:
{% if sensor.model == 'gimbal_speed' or sensor.model == 'gimbal_position' -%}
<model name='{{ sensor.sensor_attached }}'>
{% else -%}
<model name='{{ sensor.name }}'>
<pose relative_to="base_link">{{ sensor.pose }}</pose>
{% endif -%}
<link name="wide_camera">
<inertial>
<mass>0.005</mass>
<inertia>
<ixx>8.33e-06</ixx>
<iyy>8.33e-06</iyy>
<izz>8.33e-06</izz>
</inertia>
</inertial>
<sensor name="wide_camera" type="camera">
<always_on>1</always_on>
<update_rate>30</update_rate>
<camera name="wide_camera">
<horizontal_fov>2.0944</horizontal_fov>
<lens>
<intrinsics>
<!-- fx = fy = width / (2 * tan(hfov / 2)) -->
<fx>184.75</fx>
<fy>184.75</fy>
<!-- cx = (width + 1) / 2, cy = (height + 1) / 2 -->
<cx>320.5</cx>
<cy>240.5</cy>
<s>0</s>
</intrinsics>
</lens>
<image>
<width>640</width>
<height>480</height>
<format>R8G8B8</format>
</image>
<clip>
<near>0.05</near>
<far>100</far>
</clip>
{% if sensor.model == 'gimbal_speed' or sensor.model == 'gimbal_position' -%}
<optical_frame_id>/{{ namespace }}/{{ sensor.name }}/_0/_1/_2/{{ sensor.sensor_attached }}/{{ sensor.sensor_attached_type }}/optical_frame</optical_frame_id>
{% else -%}
<optical_frame_id>/{{ namespace }}/{{ sensor.name }}/{{ sensor.model }}/optical_frame</optical_frame_id>
{% endif -%}
</camera>
</sensor>
</link>
<frame name="mount_point"/>
</model>
{% if sensor.model != 'gimbal_speed' and sensor.model != 'gimbal_position' -%}
<joint name="{{ sensor.name }}_joint" type="fixed">
<parent>base_link</parent>
<child>{{ sensor.name }}</child>
</joint>
{% endif -%}The link and the sensor are both named after the type. The camera bridge looks for the image on
/world/<world>/model/<drone>/model/<name>/link/<type>/sensor/<type>/image, so any other name
leaves the topic empty. Keep the model.config from the copy, with its name changed.
Register the type
Add the type to the camera types in src/as2_gazebo_assets/models/payload.py:
class CameraTypeEnum(str, Enum):
"""Valid camera model types."""
VGA_CAM = 'vga_camera'
HD_CAM = 'hd_camera'
SEMANTIC_CAM = 'semantic_camera'
GRAYSCALE_CAM = 'grayscale_camera'
WIDE_CAM = 'wide_camera'Each class there is one kind of sensor. Its bridges method lists the topics to bridge, built with
the functions in src/as2_gazebo_assets/bridges/bridges.py, and nodes adds custom bridge nodes,
like the static transform of a camera. A new camera type uses the camera's, so it bridges
image_raw and camera_info under sensor_measurements/<name>/. A sensor of a kind the package doesn't have yet
gets a class of its own, which also goes in the model_type list of the Payload class at the end
of the file.
Include it in the drones
Each drone template picks the sensor templates it knows by name. Add the new camera before the
gimbaled branch in every drone that should carry it, starting with
models/quadrotor_base/quadrotor_base.sdf.jinja:
{% elif sensor.model == 'wide_camera' and not sensor.gimbaled -%}
{% include 'wide_camera/wide_camera.sdf.jinja' with context %}
{% elif sensor.gimbaled -%}To mount it on a gimbal, add the same include to models/gimbal/speed_gimbal.sdf.jinja and
position_gimbal.sdf.jinja, under the check on sensor.sensor_attached_type.
Build and try it
as2 build as2_gazebo_assetsGive a drone the camera, pointing down:
payload:
- model_type: "wide_camera"
model_name: "down_camera"
xyz: [0.0, 0.0, -0.05]
rpy: [0.0, 1.5708, 0.0]The platform then bridges /drone0/sensor_measurements/down_camera/image_raw and
/drone0/sensor_measurements/down_camera/camera_info. Run the package's tests with
as2 test -v as2_gazebo_assets, and add a case for the new type to
src/as2_gazebo_assets/tests/test_models.py.
Next steps
Last updated on November 3, 2026