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. It also assumes you have a working demo-world service that launches the original maze with a functional Nav2 navigation stack. 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
  • The demo-world service launching successfully with Nav2 active
  • Basic familiarity with SDF world files and ROS 2 launch files

Starting Point: The Original Maze

The original world file sim_house.sdf.xacro defines a house layout with 30 walls. Each wall is 1 meter tall, uses the default Gazebo/Wood material, and looks identical, a featureless maze of short gray walls. While functional for basic navigation testing, this environment offers little for vision-based algorithms. Each wall in the 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>
Our goal is to create an enhanced variant of this world without modifying the original, taller walls, colored textures, and optional ArUco markers, all controlled through launch parameters.

Step 1: Gather Texture and Marker Assets

Before modifying any world files, you need the raw assets: wall textures and ArUco marker models.

Wall Textures

Create a textures/ directory inside the worlds folder and add three PNG texture images:
tb_worlds/worlds/textures/
  bricks.png      # Brick wall texture
  concrete.png    # Concrete texture
  wood_.png       # Wood grain texture
These should be standard PBR-compatible albedo maps. You can source them from texture libraries like ambientCG or Poly Haven, or create simple solid-color images for testing.

ArUco Marker Models

ArUco markers are commonly used for visual localization in robotics. Each marker model needs a directory under tb_worlds/models/ containing:
tb_worlds/models/aruco_id_60/
  model.config          # Gazebo model metadata
  model.sdf             # Model description (link, visual, collision)
  meshes/
    aruco_cube_60.obj   # 3D mesh
    aruco_cube_60.mtl   # Material definition
  materials/textures/
    aruco_60.jpg        # Marker face texture
Create similar directories for each marker ID you want (e.g., aruco_id_60, aruco_id_80). You can generate ArUco marker images using OpenCV’s cv2.aruco module and wrap them onto cube meshes using a 3D tool like Blender.
If your ArUco model SDFs reference meshes via Gazebo Fuel URLs (e.g., https://fuel.gazebosim.org/...), replace them with local model:// URIs. Fuel URLs 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>

{/* After: local model URI */}
<uri>model://aruco_id_80/meshes/aruco_cube_80.obj</uri>
The model:// URI scheme resolves via GZ_SIM_RESOURCE_PATH, which we configure in Step 5.

Install Assets via CMakeLists.txt

Verify that your package’s CMakeLists.txt installs the new directories. The tb_worlds package already includes:
install(DIRECTORY ... worlds models ... DESTINATION share/${PROJECT_NAME})
This covers both the textures/ subdirectory (inside worlds/) and the new models/ entries. If your CMakeLists doesn’t install models, add it.

Step 2: Create the Enhanced World SDF

Copy sim_house.sdf.xacro to sim_house_enhanced.sdf.xacro and make three categories of changes: taller walls, shifted centers, and PBR textures with color fallbacks.

Add the Xacro Texture Argument

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

Transform 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>'
)

Replace 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 across the walls 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 at runtime.
# 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 without breaking existing behavior:
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 for world_name 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. It must include both the worlds/ directory (for texture files) and the models/ directory (for ArUco marker meshes).

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/*.pngWall texture images (brick, concrete, wood)
tb_worlds/models/aruco_id_\{60,80\}/ArUco marker models with local mesh URIs
tb_worlds/worlds/sim_house_enhanced.sdf.xacroNew enhanced world (3m walls, PBR + color fallbacks)
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