Introduction to Unity Robotics and Simulation Pro · Module 4 of 5
Simulation time, coordinate conversion, and connecting Unity to a live ROS 2 environment — then a complete simulation in which a Python node navigates a robot around a room using its LiDAR.
By the end of this module you will have a robot driving itself around a Unity Scene under the control
of an rclpy node running on a separate machine, exchanging LiDAR, odometry, and velocity messages
over a live connection.
Go to Assets > Import Package > Custom Package and import this module's asset package.
From this point on you work in the differential drive LiDAR Scene: a simple room containing the AMR robot and furniture.
Every ROS 2 message carries a timestamp. That is the reason time management matters: whatever reads your messages downstream needs to know when each one was produced, and the value in that field depends entirely on who is keeping time.
SimPro's single source of truth for time is:
double simulationTime = Clock.Now;Clock.Now returns the time in seconds recorded at the beginning of the current frame. Called from
FixedUpdate, it returns the fixed-update start time; called from Update, the frame start time.
Every timestamp in the simulation comes from here.
The mode is assigned automatically at runtime based on what is in the Scene:
| Mode | Selected when | Time comes from |
|---|---|---|
| Unity Unscaled | No GameObject has a TimeStepApplier |
Unscaled Unity time: Time.timeAsDouble and Time.deltaTimeAsDouble |
| Foundation Clock | A GameObject has an enabled TimeStepApplier |
The applier, advancing only when its Step method is called |
| External Clock | Distributed rendering, or an external source publishing clock messages | Outside Unity |
If Unity keeps time, the simulation should tell ROS 2 what time it thinks it is.
- Create an empty GameObject named
Time Manager. - Add a Clock Publisher component.
ClockPublisher derives from the PublisherBehaviour class from Module 2. On Update or
FixedUpdate it publishes a clock message carrying the last published time, broadcasting it to any
ROS subscriber that needs it.
You may not want Unity's frame rate deciding how fast the simulation runs. Deterministic stepped simulation needs time to advance in fixed, explicit increments.
Add a Time Step Scheduler. It automatically brings a Time Step Applier with it.
| Component | Role |
|---|---|
| Time Step Scheduler | Decides when to advance. In LateUpdate — the end of the frame — it checks whether stepping is currently allowed and calls StepTimeForward. |
| Time Step Applier | Takes explicit control of Unity's time loop. Advances simulation time only when its Step method is called. |
Set Time step in seconds on the scheduler to how much time one simulation update represents. With Unity Unscaled Time, time advances by however long the frame took; here you set it yourself.
Pause conditions. The scheduler also accepts IPauseCondition implementations. While any
registered condition is true, the scheduler stops stepping time and resumes when it no longer
applies. Between the scheduler, the applier, and this interface you have complete control over
simulation time.
The third case: Unity is not the authority on time at all.
Remove the scheduler and add a Clock Subscriber instead. It listens for clock updates arriving
through the ROS 2 connector and hands them to the TimeStepApplier, which applies them to the
simulation clock.
Unity Unscaled Time, and nothing else. Remove the Time Step Scheduler, the Time Step Applier, and the Clock Subscriber, leaving only a Clock Publisher on the Time Manager. Unity keeps its own unscaled time and publishes it every frame.
Time Managerexists in the Hierarchy with a Clock Publisher component.- No
TimeStepApplierremains in the Scene, so the simulation runs in Unity Unscaled mode.
Unity uses RUF - right, up, forward. Positive X points right, positive Y points up, positive Z points forward.
| System | Convention | Used by |
|---|---|---|
| RUF | X right, Y up, Z forward | Unity |
| LUF | X left, Y up, Z forward | Unreal Engine |
| RHR | X right, Y back, Z up | Blender and other right-hand-rule software |
| FLU | X forward, Y left, Z up | ROS and ROS 2 ground vehicles |
| NED / ENU | North-east-down / east-north-up | Real-world compass positioning |
A robot that drives the wrong way is very often a coordinate problem. Unity's Z and ROS's X both mean "forward", so an unconverted position arrives rotated.
One of SimPro's core jobs is converting between Unity and ROS FLU in both directions so your robot moves the way you intend.
Compass settings. NED and ENU account for
CompassDirection.UnityZAxisDirection, so geographic north, east, south, and west map correctly regardless of how your Scene is oriented. Set this under Simulation > Simulation Settings > Compass Settings.
SimPro provides conversion methods you call on the value itself:
| Direction | Method | Use when |
|---|---|---|
| Unity → ROS | .To with FLU |
Before publishing a position, orientation, or velocity to ROS 2 |
| ROS → Unity | .From with FLU |
On receiving a message from ROS 2, before using the value in the Scene |
These work on point messages, quaternions, and any other value that needs translating between the two systems.
using UnityEngine;
using Unity.SimulationPro.Foundation;
using Unity.SimulationPro.Foundation.GeometryMessages;
public class MessageConversions : MonoBehaviour
{
private void Start() {
var ourMsg = ToMessage(transform.position);
ToUnity(ourMsg);
}
PointMsg ToMessage(Vector3 position)
{
Debug.Log(position.To<FLU>());
return position.To<FLU>();
}
Vector3 ToUnity(PointMsg msg)
{
Debug.Log(msg.From<FLU>());
return msg.From<FLU>();
}
}The tell is which axis holds which number. If the robot's transform shows Z = -3, that value appears in Unity form on Z and in ROS form on X, because the two systems swap those axes. That is the conversion working.
- The Console prints two coordinate values per log cycle.
- The Unity value matches the Inspector's transform; the FLU value has X and Z exchanged.
Modules 1–3 used a Dummy Connection, which fakes a ROS environment so messaging can be tested in isolation. To talk to a real ROS 2 system, swap it for the ROS Endpoint Connector - the component that handles communication with external ROS services.
- Create an empty GameObject named
ConnectionManager. - Add a Ros Endpoint Connector component.
- Remove or disable the Dummy Connection.
Configuration is three fields:
| Field | Value |
|---|---|
| IP address | The ROS machine's address (below) |
| Port | The endpoint's port: 10000. |
| Is ROS 2 | Checked. Uncheck it only if you are on ROS 1. |
The course uses a virtual machine running Ubuntu 26.04 and ROS 2 Lyrical Luth. Setting up ROS 2 is documented on the ROS 2 Wiki. The simulation test files are included in the course assets.
Get the machine's address:
hostname -IOpen the port through the firewall:
sudo ufw allow <port>Source the ROS 2 environment:
source install/setup.bashStart the Unity endpoint server:
ros2 run ros_tcp_endpoint default_server_endpoint --ros-args -p ROS_IP:=0.0.0.0ROS_IP:=0.0.0.0 accepts connections on every interface rather than localhost only, which is what
makes the connection possible from a separate Unity machine.
Leave the endpoint running. It must stay open for the whole session. If you need the terminal, open a new tab or a second terminal instance rather than stopping it.
Press Play in Unity.
- Unity's Console reports a successful connection to the IP and port.
- The VM's terminal logs an incoming connection from Unity.
On the ROS 2 side is a package containing waypoint_navigator.py, written with rclpy —
the official library for working with ROS 2 from Python. It implements waypoint navigation with
obstacle avoidance, driven by LiDAR data.
Unity ROS 2 (rclpy node)
───── ──────────────────
Physics LiDAR ──────/scan──────────► waypoint_navigator.py
WheelOdometryPublisher ──/odom─────► ├─ reads scan + odometry
├─ runs navigation algorithm
CmdVelDifferentialDriveBridge ◄─/cmd_vel─┘
└─► DifferentialDriveController
└─► ArticulationBody drives wheels
└─► new scan and odometry ──► (repeat)
The Python script reads the LiDAR and the odometer, computes where to go, and sends back linear and angular velocity. Unity drives the wheels, the robot moves, and the next scan and odometry reading go out. Unity is standing in for the physical robot, which also means you can test the same node across different environments before deploying it.
The navigator's internals are mostly trigonometry for angles and waypoints. You do not need to follow the math to use it.
Sensor prefabs must sit at position 0, 0, 0. That is a hard constraint, and it is why you always add a sensor under a parent object and position the parent instead of the sensor.
- Open the AMR prefab and find the
front_laser_linkobject. - Use it as the LiDAR's parent. Negate its rotation so the sensor faces origin (0, 0, 0).
- Position it above the middle of the robot.
- Add a Physics LiDAR sensor under it.
Configure the sensor:
| Setting | Value | Why |
|---|---|---|
| Topic | /scan |
What the Python script subscribes to |
| Update rate | 20 |
Publishes often enough for responsive navigation |
| Output type | Laser scan | The navigator expects a planar scan, not a point cloud |
- The LiDAR sensor's local position is 0, 0, 0 and its parent carries the offset.
- Its topic reads
/scanand its output type is laser scan.
The AMR prefab carries five scripts. Three participate in ROS messaging; two are pure Unity. Disable
the Time Step Applier if one is still present, and delete the MessageConversions script from
section 3.
| Script | ROS? | What it does |
|---|---|---|
| DifferentialDriveController | No | Pure physics. Turns the wheels through their ArticulationBody components. |
| DifferentialDriveKeyboardAdapter | No | Drive the robot from the keyboard through the Input System, to confirm the physics works before involving ROS. |
| DifferentialAuthoring | No | Walks an array of the wheels and sets strength, mass, damping, and material on each ArticulationBody. |
| WheelOdometryPublisher | Publishes /odom |
Simulates an odometry sensor. |
| CmdVelDifferentialDriveBridge | Subscribes /cmd_vel |
Receives velocity commands and hands them to the controller. |
No ROS functionality at all: it neither sends nor receives messages. It holds a differential drive
control message supplied by another script, stores it through SetCommand as the last message
received, and in SetVelocity reads the message's linear velocity (on Z) and angular velocity and
turns the wheels accordingly.
An odometer measures how far the wheels have turned, in radians. The Python script needs that to calculate distance travelled.
Its default topic is /odom. In CreateMessage it builds an odometry message that is
already in ROS FLU coordinates, so no conversion is needed. That message contains two others:
| Contained message | What it holds |
|---|---|
| Pose with covariance | The current position and orientation of the wheel |
| Twist | The current linear and angular velocity |
A twist message expresses velocity in free space, in two parts: linear speed (moving forward, sideways, up, or down) and angular speed (turning or spinning on an axis). This is the standard message type for driving robots.
The distance and radian math is commented in the script if you want to follow it.
This is the return leg. It receives /cmd_vel twist messages from the Python node in a callback,
reads out the linear and angular velocities, clamps them to maximum speeds, and stores them. In
Update it calls ConsumeMessage on the DifferentialDriveController, passing a differential drive
control message.
Note that the bridge converts coordinates manually rather than calling .To and .From.
- Five scripts are present on the AMR prefab, with
MessageConversionsremoved. - Pressing Play with the keyboard adapter enabled drives the robot, confirming the physics works independently of ROS.
On the ROS machine, in order:
source install/setup.bash
ros2 run ros_tcp_endpoint default_server_endpoint --ros-args -p ROS_IP:=0.0.0.0Then, in a second terminal, source again and start the navigator:
source install/setup.bash
ros2 run simpro_waypoint_nav waypoint_navigator --ros-args \
-p waypoints:="[0.4, -1.9, 1.8, -2.0, 4.9, -2.2, 4.6, 1.9, 1.5, 2.0, 0.1, 0.0]" \
-p emergency_stop_cone_deg:=40.0 \
-p loop_waypoints:=trueThe waypoints are X/Y pairs. loop_waypoints:=true sends the robot back to the first waypoint after
the last one, so it circles indefinitely.
Now press Play in Unity.
The robot begins navigating the living room, using the LiDAR to detect obstacles in its path. Switch to the Scene view to see the scan showing the robot where the gaps are.
- The robot moves without being driven by the keyboard.
- The LiDAR scan visibly updates as it advances.
- It reaches waypoints in sequence and loops back to the first.
It may get stuck, particularly in corners and against walls. In the recording the robot wedges itself in a corner, works at it for a while, and then heads into a wall.
This is expected behaviour from a navigation algorithm with limited obstacle recovery, not a broken setup. Stop and replay the Scene. The robot resumes toward the waypoint it was pursuing, finds its way around the room, and loops back to waypoint one.
That is also the point of simulating first. You find these failures in a Unity living room rather than a real one, and the next step from here is testing the same node on hardware in a real room.
You now have a complete simulation: sensors, messaging, and a live ROS 2 Linux environment driving a robot in Unity.
Next: Module 5 — Capturing Data, which covers getting data back out of the simulation: images, point clouds, and CSV logs.





