Skip to main content
Simulation environments play a critical role in robotics development. A richer visual environment helps test vision-based navigation, SLAM algorithms, and marker detection before deploying to hardware. In this tutorial, we walk through enhancing a TurtleBot3 maze world by adding taller walls with PBR textures, ArUco marker models, and a parameterized launch system — all while keeping the original world unchanged.

What You’ll Build

By the end of this tutorial, you’ll have:
  • An enhanced maze world (sim_house_enhanced.sdf.xacro) with 3-meter walls and colored PBR textures
  • ArUco marker models that can optionally spawn in the environment
  • A parameterized launch system that lets you switch between the original and enhanced worlds
  • A new Docker Compose service (demo-world-enhanced) for one-command launch
This tutorial assumes familiarity with ROS 2 (Jazzy), Gazebo Sim (gz-sim), SDF/Xacro world files, and Docker Compose. The complete source is in the turtlebot-maze repository on the feature/enhanced-maze branch.

Prerequisites

  • A working clone of the turtlebot-maze repository
  • Docker and Docker Compose installed
  • Basic familiarity with SDF world files and ROS 2 launch files

Step 1: Cherry-Pick Assets from a Feature Branch

Real-world repositories often accumulate useful assets in PRs that aren’t merge-ready. In our case, PR #3 contained ArUco marker models and wall textures mixed with broken spawners and hardcoded paths. Rather than merging the entire PR, we selectively cherry-pick the assets we need.
# Create a feature branch
git checkout -b feature/enhanced-maze

# Cherry-pick only the assets we want
git checkout pr3-temp -- \
  tb_worlds/worlds/textures/bricks.png \
  tb_worlds/worlds/textures/concrete.png \
  tb_worlds/worlds/textures/wood_.png \
  tb_worlds/models/aruco_id_60 \
  tb_worlds/models/aruco_id_80
This gives us three wall textures (brick, concrete, wood) and two ArUco marker models (IDs 60 and 80), each containing OBJ meshes, MTL materials, and JPEG textures.
When cherry-picking from messy PRs, inspect the file tree first with git ls-tree --name-only -r <branch> -- <path> to see exactly what you’re pulling in.

Fix Model Mesh URIs

The cherry-picked ArUco models referenced meshes via Gazebo Fuel URLs, which require network access and fail inside Docker containers:
{/* Before: Fuel URL (requires network) */}
<uri>https://fuel.gazebosim.org/1.0/myoan/models/aruco cube id 80/2/files/meshes/aruco_cube_80.obj</uri>
Replace these with local model:// URIs that gz-sim resolves via GZ_SIM_RESOURCE_PATH:
{/* After: local model URI */}
<uri>model://aruco_id_80/meshes/aruco_cube_80.obj</uri>

Step 2: Create the Enhanced World SDF

The enhanced world is a copy of the original sim_house.sdf.xacro with three modifications:
  1. Taller walls — height increased from 1m to 3m
  2. Shifted wall centers — z-center moved from 0.5 to 1.5 to match the new height
  3. PBR textures with color fallbacks — replacing the default Gazebo/Wood material

Understanding the Original SDF Structure

Each wall in the original SDF follows this pattern:
<link name='Wall_0'>
  <collision name='Wall_0_Collision'>
    <geometry>
      <box>
        <size>2.5 0.15 1</size>     {/* width depth HEIGHT */}
      </box>
    </geometry>
    <pose>0 0 0.5 0 -0 0</pose>     {/* z-center = height/2 */}
  </collision>
  <visual name='Wall_0_Visual'>
    <pose>0 0 0.5 0 -0 0</pose>
    <geometry>
      <box>
        <size>2.5 0.15 1</size>
      </box>
    </geometry>
    <material>
      <script>
        <uri>file://media/materials/scripts/gazebo.material</uri>
        <name>Gazebo/Wood</name>
      </script>
    </material>
  </visual>
</link>

Adding the Xacro Texture Argument

Add a texture_base xacro argument after the existing headless argument. This will receive the absolute path to the texture directory at launch time:
<xacro:arg name="headless" default="false"/>
<xacro:arg name="texture_base" default=""/>

Transforming Wall Geometry

For all 30 walls, make these changes:
PropertyOriginalEnhanced
<size> height (3rd component)13
<pose> z-center (3rd component)0.51.5
With 30 walls, each having both collision and visual elements, this means 60 size changes and 60 pose changes. A Python script makes this reliable:
import re

with open('sim_house.sdf.xacro', 'r') as f:
    content = f.read()

# Change wall heights from 1 to 3
def replace_wall_size(m):
    parts = m.group(2).split()
    if len(parts) == 3 and parts[2] == '1':
        parts[2] = '3'
    return m.group(1) + ' '.join(parts) + m.group(3)

content = re.sub(
    r'(<size>)([\d.]+ [\d.]+ 1)(</size>)',
    replace_wall_size,
    content
)

# Shift z-centers from 0.5 to 1.5
content = content.replace(
    '<pose>0 0 0.5 0 -0 0</pose>',
    '<pose>0 0 1.5 0 -0 0</pose>'
)

Replacing Materials with PBR Textures and Color Fallbacks

Gazebo Sim (gz-sim) uses the ogre2 render engine which supports PBR materials. Replace each Gazebo/Wood material block with a PBR <albedo_map> texture reference. Crucially, also add <ambient> and <diffuse> color tags as fallbacks — these ensure walls are visually distinct even when PBR textures don’t render:
{/* Enhanced material: color fallback + PBR texture */}
<material>
  <ambient>0.7 0.3 0.2 1</ambient>
  <diffuse>0.8 0.4 0.3 1</diffuse>
  <pbr>
    <metal>
      <albedo_map>$(arg texture_base)/bricks.png</albedo_map>
    </metal>
  </pbr>
</material>
Cycle through three texture/color combinations for visual variety:
TextureAmbient ColorDiffuse Color
bricks.png0.7 0.3 0.2 (brick red)0.8 0.4 0.3
concrete.png0.6 0.6 0.6 (gray)0.7 0.7 0.7
wood_.png0.5 0.35 0.2 (brown)0.6 0.4 0.25
PBR textures in gz-sim require absolute file paths in <albedo_map> when the SDF is generated from xacro into a temp file (since relative paths would resolve from /tmp/). The texture_base xacro argument solves this by injecting the absolute path at launch time.

Step 3: Create the ArUco Marker Spawner

The ArUco marker spawner is a ROS 2 launch file that uses ros_gz_sim’s gz_spawn_model.launch.py to place markers in the simulation.
# tb_worlds/launch/aruco_marker_spawner.launch.py
import os
import tempfile
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, TimerAction
from launch.launch_description_sources import PythonLaunchDescriptionSource

def generate_launch_description():
    pkg_dir = get_package_share_directory("tb_worlds")
    gz_spawn_launch = os.path.join(
        get_package_share_directory("ros_gz_sim"),
        "launch", "gz_spawn_model.launch.py",
    )

    markers = [
        {"name": "aruco_id_80", "model_dir": "aruco_id_80",
         "x": "-1.0", "y": "-2.0", "z": "0.01", "Y": "0"},
        {"name": "aruco_id_60", "model_dir": "aruco_id_60",
         "x": "-1.5", "y": "-2.05", "z": "0.23", "Y": "0"},
    ]

    spawn_actions = []
    for m in markers:
        model_dir = os.path.join(pkg_dir, "models", m["model_dir"])
        sdf_path = os.path.join(model_dir, "model.sdf")

        # Substitute model:// URIs with absolute paths
        with open(sdf_path, "r") as f:
            sdf_content = f.read()
        sdf_content = sdf_content.replace(
            f"model://{m['model_dir']}/", f"{model_dir}/"
        )

        # Write temp SDF with resolved paths
        tmp_sdf = tempfile.NamedTemporaryFile(
            prefix=f"aruco_{m['name']}_", suffix=".sdf",
            mode="w", delete=False,
        )
        tmp_sdf.write(sdf_content)
        tmp_sdf.close()

        spawn_actions.append(
            IncludeLaunchDescription(
                PythonLaunchDescriptionSource(gz_spawn_launch),
                launch_arguments={
                    "world": "",
                    "file": tmp_sdf.name,
                    "entity_name": m["name"],
                    "x": m["x"], "y": m["y"],
                    "z": m["z"], "Y": m["Y"],
                }.items(),
            )
        )

    # Delay to allow gz-sim to fully initialize
    delayed_spawn = TimerAction(period=10.0, actions=spawn_actions)
    return LaunchDescription([delayed_spawn])

Key Implementation Details

The gz_spawn_model.launch.py in ROS 2 Jazzy declares its launch argument as entity_name, which it then maps to the name ROS parameter internally. Passing name directly as a launch argument has no effect — the parameter stays empty and the spawn fails.
The ros_gz_sim create node reads the SDF file and sends its content to the gz-sim server. If the SDF contains model:// URIs, the create node may crash (SIGABRT) because it tries to resolve them locally before the server does. Writing a temp SDF with absolute paths avoids this issue entirely.
The ArUco spawner launches in parallel with gz-sim. If the create service call arrives before gz-sim’s entity creation service is ready, the spawner crashes. A TimerAction delay gives gz-sim time to initialize.

Step 4: Parameterize the Launch File

The existing tb_demo_world.launch.py hardcodes the world file. We add two new launch arguments to make it configurable:
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution

# New launch arguments
world_name = LaunchConfiguration("world_name")
use_aruco = LaunchConfiguration("use_aruco")

declare_world_name_cmd = DeclareLaunchArgument(
    "world_name",
    default_value="sim_house.sdf.xacro",
    description="World filename relative to tb_worlds/worlds/",
)

declare_use_aruco_cmd = DeclareLaunchArgument(
    "use_aruco",
    default_value="False",
    description="Whether to spawn ArUco markers",
)

# Construct full world path from world_name
world_path = PathJoinSubstitution([bringup_dir, "worlds", world_name])
Pass the constructed path to tb_world.launch.py:
sim_cmd = IncludeLaunchDescription(
    PythonLaunchDescriptionSource(
        os.path.join(bringup_dir, "launch", "tb_world.launch.py")
    ),
    launch_arguments={
        "namespace": namespace,
        "use_sim_time": use_sim_time,
        "world": world_path,
    }.items(),
)
Add the conditional ArUco spawner:
aruco_spawner_cmd = IncludeLaunchDescription(
    PythonLaunchDescriptionSource(
        os.path.join(bringup_dir, "launch", "aruco_marker_spawner.launch.py")
    ),
    condition=IfCondition(use_aruco),
)

Passing texture_base Through to Xacro

The tb_world.launch.py runs xacro on the world file. It needs to forward the texture_base argument so the enhanced SDF can resolve texture paths. Add a texture_base launch argument with a sensible default:
declare_texture_base_cmd = DeclareLaunchArgument(
    "texture_base",
    default_value=os.path.join(bringup_dir, "worlds", "textures"),
    description="Absolute path to wall texture directory",
)
And pass it to the xacro command using a nested list (ROS 2 launch concatenates adjacent elements):
world_sdf_xacro = ExecuteProcess(
    cmd=[
        "xacro", "-o", world_sdf, world,
        ["texture_base:=", texture_base],
    ]
)
The default value sim_house.sdf.xacro means existing users of demo-world are completely unaffected — the original world loads exactly as before.

Step 5: Add the Docker Compose Service

Add a new service after demo-world in docker-compose.yaml:
# Enhanced demo world with taller textured walls and ArUco markers
demo-world-enhanced:
  extends: overlay
  environment:
    - GZ_SIM_RESOURCE_PATH=/overlay_ws/install/tb_worlds/share/tb_worlds/worlds:/overlay_ws/install/tb_worlds/share/tb_worlds/models
  command: >
    ros2 launch tb_worlds tb_demo_world.launch.py
    world_name:=sim_house_enhanced.sdf.xacro
    use_aruco:=True
The GZ_SIM_RESOURCE_PATH environment variable is critical — it tells gz-sim where to find model:// URIs for mesh and texture resolution.

Step 6: Build and Launch

# Build the image
docker compose build demo-world-enhanced

# Launch ONLY the enhanced world (not all services!)
docker compose up demo-world-enhanced
Never run docker compose up without specifying a service. Because all services use network_mode: host, launching both demo-world and demo-world-enhanced simultaneously creates two gz-sim instances competing for the same ports, resulting in two Gazebo windows and a broken Nav2 stack.

Verification

After launch, you should see:
  1. Gazebo Sim — Tall colored walls (brick red, concrete gray, wood brown) instead of the original short gray walls
  2. RViz2 — The map loads and Nav2 shows “active” for both Navigation and Localization
  3. Nav2 — Fully functional; the same sim_house_map.yaml and costmaps work because the wall footprint is unchanged
Check the logs for confirmation:
docker compose logs demo-world-enhanced | grep "Managed nodes are active"
You should see two lines — one for lifecycle_manager_localization and one for lifecycle_manager_navigation.

Summary of Files Changed

FileChange
tb_worlds/worlds/textures/*.pngCherry-picked wall textures
tb_worlds/models/aruco_id_\{60,80\}/Cherry-picked ArUco models with fixed mesh URIs
tb_worlds/worlds/sim_house_enhanced.sdf.xacroNew enhanced world (3m walls, PBR + colors)
tb_worlds/launch/aruco_marker_spawner.launch.pyNew ArUco spawner with temp SDF and delay
tb_worlds/launch/tb_demo_world.launch.pyAdded world_name and use_aruco params
tb_worlds/launch/tb_world.launch.pyAdded texture_base xacro passthrough
docker-compose.yamlAdded demo-world-enhanced service

Next Steps

  • Add more textures — Download additional PBR texture packs and assign them per-room
  • Fix ArUco spawning — The OBJ mesh spawning via ros_gz_sim create currently triggers SIGABRT in some gz-sim versions; consider embedding markers directly in the world SDF instead
  • Create a SLAM-friendly variant — The taller walls improve SLAM performance since the lidar has more surface to reflect off of at different heights
  • Add ceiling and floor textures — Complete the visual environment for camera-based navigation testing