A browser-based, ROS 2 compatible simulation of an autonomous Unmanned Surface Vehicle (USV)
operating in a stylized model of the Istanbul Bosphorus. The vehicle is driven by a
from-scratch rigid-body hydrodynamics model, exposes a full ROS 2 sensor and command
interface over rosbridge, and includes an in-browser mission planner that lets the operator
draw a route and launch autonomous line-following with a single button.
- Vehicle Model and Physics
- Installation
- Running the Simulation
- ROS 2 Interface: Message Types and Flow
- Autonomous Mode and the Mission Planner
- Manual Control and Camera
- Environment Editor
- Visualization in RViz2
- Project Structure
- Troubleshooting
All ROS output follows REP-103 (ENU / FLU). Internally the renderer uses the Three.js convention, and conversions are applied at the sensor boundary:
| Quantity | Three.js world | ROS (ENU / FLU) |
|---|---|---|
| Axes | +X East, +Y Up, +Z South | East = x, North = -z, Up = y |
| Vector | (x, y, z) | (x, -z, y) |
| Orientation | quaternion (qx,qy,qz,qw) | (qx, -qz, qy, qw) |
| Body frame | +X forward, +Z starboard | FLU: forward = +X, left = -Z, up = +Y |
| Heading | object.rotation.y |
ENU yaw (0 = East, CCW positive) |
The GPS origin (the world origin) is fixed at latitude 41.0451 N, longitude 29.0330 E, in the middle of the strait.
The controlled vehicle is a fast interceptor-class USV (inspired by vehicles such as the ULAQ class). It is a monohull with an enclosed, uncrewed foredeck, a sensor mast, and twin stern propulsion. The visual model is the "Cora" USV mesh from the OSRF VORC project (see Model Credits).
The physical hull used for buoyancy and drag is a box approximation of the vessel:
| Parameter | Value | Notes |
|---|---|---|
| Length (L) | 11.0 m | overall |
| Beam (B) | 3.4 m | overall |
| Hull depth | 1.6 m | keel to deck |
| Mass | 5 500 kg | equilibrium draft approximately 0.26 m |
| Block coefficient (Cb) | 0.55 | planing-style hull |
| Max thrust per propeller | 25 000 N | thrust-to-weight approximately 0.9 g |
| Top speed | ~15 m/s | approximately 30 knots |
| Max yaw rate | 0.9 rad/s | at full differential thrust |
| Thruster response | tau = 0.25 s | first-order lag |
All of these values live in a single file, web/js/config.js, which is
the central tuning point for physics, sensors, environment, and rendering.
The ocean is a sum of four Gerstner wave components. Each component is defined by a direction,
amplitude, wavelength, and steepness; its angular frequency is derived from the deep-water
dispersion relation omega = sqrt(g * k), with k = 2*pi / wavelength.
Crucially, the same wave formula and the same parameters are evaluated in two places:
- On the CPU (
web/js/waves.js) for the physics.getHeight(x, z, t)returns the true surface elevation, inverting the horizontal Gerstner displacement with three fixed-point iterations. - On the GPU (
web/js/ocean.js) in a custom vertex shader for the visuals.
Because both use identical parameters, the vessel physically rides the exact wave it appears to sit on: pitch, roll, and heave are emergent, not scripted.
Every fixed physics step accumulates forces and torques in the world frame, then integrates
with semi-implicit Euler. There is no third-party physics engine; the dynamics are written
directly in web/js/physics.js.
-
Buoyancy (multi-point). A 5 x 3 grid of sample points is distributed over the hull bottom. Each point is transformed into the world frame, and its submersion depth is measured against the Gerstner surface at that instant. Each submerged point contributes an upward Archimedes force
rho * g * h * cellAreaapplied at the point, which produces both heave support and the restoring torque that keeps the vessel upright and makes it pitch and roll with the sea. -
Hydrodynamic drag. Damping is applied in the body frame with independent linear and quadratic coefficients for surge (forward), sway (lateral), and heave (vertical), scaled by the submerged fraction. Rotational drag is applied about the body roll, yaw, and pitch axes in the same way. The quadratic surge term is what caps the top speed near 15 m/s.
-
Righting torque. In addition to the distributed buoyancy, an explicit restoring torque proportional to roll and pitch angle adds stability and prevents capsizing in steep waves.
-
Propulsion. Two stern thrusters produce force along the body +X axis, each applied at its offset from the center of gravity (port at -Z, starboard at +Z). Thrust follows the command through a first-order lag (tau = 0.25 s), and a thruster only produces force while it is actually submerged.
-
Contact. Hull corner points are tested against the analytic terrain height field for grounding, and a bounding sphere is rejected against environment object colliders (boxes, cylinders, spheres), so the vessel cannot drive through bridge piers, docked ships, or the shore.
-
Integration. Linear:
v += (F / m) * dt, thenposition += v * dt, with a safety clamp on speed. Angular: world torque is rotated into the body frame, divided by a box inertia tensor, integrated, and converted back; the orientation quaternion is advanced byq += 0.5 * omega_world * q * dtand renormalized. A NaN guard resets the vehicle if the state ever becomes non-finite.
The vehicle is steered by differential thrust. A geometry_msgs/Twist on /usv/cmd_vel is
mapped to normalized thruster commands:
forward = clamp(linear.x / max_speed, -1, 1) # max_speed = 15 m/s
turn = clamp(angular.z / max_yaw_rate, -1, 1) # max_yaw_rate = 0.9 rad/s
port_thruster = clamp(forward - turn, -1, 1)
starboard_thruster = clamp(forward + turn, -1, 1)
A positive angular.z (counter-clockwise, i.e. a turn to port) makes the starboard thruster
push harder, which is the physically correct sense. Thruster commands can also be sent directly
via /usv/thrusters.
Deterministic, hidden-tab-safe stepping
Physics runs at a fixed 60 Hz, driven by a setInterval heartbeat inside an inline Web Worker.
This decouples the physics and ROS publishing rates from the render loop (requestAnimationFrame),
so the simulation keeps stepping at the correct rate even when the browser tab is in the
background and rendering is throttled.
- ROS 2 Humble (Ubuntu 22.04 recommended)
- rosbridge_suite (
ros-humble-rosbridge-suite) - RViz2 (
ros-humble-rviz2), optional but recommended - Python 3.10+
- A WebGL-capable browser (Chrome or Firefox)
Install the ROS dependencies with apt:
sudo apt update
sudo apt install ros-humble-rosbridge-suite ros-humble-rviz2If rosbridge is not available as a package on your system, the helper script
scripts/setup_rosbridge.sh builds it from source into ~/rosbridge_ws. Note that it also
removes the standalone bson pip package if present, because it shadows PyMongo's bson
module and breaks rosbridge.
git clone <this-repository> tekne_simulation
cd tekne_simulation
# Build the usv_sim package
source /opt/ros/humble/setup.bash
cd ros2_ws
colcon build --symlink-install
cd ..The helper scripts/build_ros2.sh performs the same build. The environment helper
scripts/env.sh sources ROS 2, rosbridge, and this workspace in one step; if your ROS 2 or
rosbridge installations live somewhere other than the defaults, edit the paths at the top of
that script.
You need two things running: the web server that serves the 3D client, and the ROS 2 stack that bridges it to the network.
scripts/run_all.shThis starts the web server in the background and brings up the full ROS stack (rosbridge + monitor + viz + mission_manager) in the foreground. It is everything needed for both manual and autonomous operation, including the mission planner's "Start Autonomous" button. Press Ctrl-C to stop; the background web server is torn down automatically.
Then open http://localhost:8080 in a browser. The status indicator in the top-left corner turns green ("ROS connected") once the bridge is reachable.
# Terminal 1: serve the web client on http://localhost:8080
scripts/serve_web.sh
# Terminal 2: full ROS stack (rosbridge + monitor + viz + mission_manager)
scripts/mission.shusv_bringup.launch.py (used by scripts/rviz.sh and the manual/teleop workflows) brings up
only rosbridge and the monitor. The mission planner's autonomous launch requires the
mission_manager node, which is included in mission_planner.launch.py and in run_all.sh.
scripts/serve_web.py serves the client with a Cache-Control: no-store header so that code
changes are always picked up on reload during development.
Only one browser tab should publish at a time. The client uses a single-instance lock, but it only coordinates tabs of the same origin. If two simulator tabs are connected to the same bridge, both publish odometry and the ROS side receives interleaved, conflicting state. Keep a single simulator tab open.
| Topic | Type | Direction | Rate |
|---|---|---|---|
/usv/gps |
sensor_msgs/NavSatFix |
sim -> ROS | 5 Hz |
/usv/imu |
sensor_msgs/Imu |
sim -> ROS | 50 Hz |
/usv/odom |
nav_msgs/Odometry |
sim -> ROS | 20 Hz |
/usv/velocity |
geometry_msgs/TwistStamped |
sim -> ROS | 20 Hz |
/usv/scan |
sensor_msgs/LaserScan |
sim -> ROS | 10 Hz |
/tf |
tf2_msgs/TFMessage |
sim -> ROS | 20 Hz |
/usv/cmd_vel |
geometry_msgs/Twist |
ROS -> sim | - |
/usv/thrusters |
std_msgs/Float32MultiArray |
ROS -> sim | - |
/usv/reset |
std_msgs/Empty |
ROS -> sim | - |
/usv/mission/waypoints |
nav_msgs/Path |
web -> ROS | - |
/usv/mission/command |
std_msgs/String (JSON) |
web -> ROS | - |
/usv/mission/status |
std_msgs/String |
ROS -> web | 1 Hz |
/usv/current_wp |
std_msgs/Int32 |
ROS -> RViz | - |
/usv/path |
nav_msgs/Path |
ROS -> RViz | 2 Hz |
/usv/markers |
visualization_msgs/MarkerArray |
ROS -> RViz | 2 Hz |
- GPS (
NavSatFix). Latitude and longitude are derived from the ENU position relative to the fixed origin, with Gaussian noise (sigma = 0.8 m) and a filled position covariance. - IMU (
Imu). Orientation is the ENU attitude quaternion; angular velocity and linear acceleration are reported in the FLU body frame. The linear acceleration is proper acceleration, so at rest the accelerometer reads +g along the body-up axis, as a real device would. Per-field Gaussian noise is applied. - Odometry (
Odometry). Pose is reported in themapframe (ENU); the twist is in the body frame (FLU). No noise is added to odometry. - LaserScan (
LaserScan). A stabilized 360-degree planar lidar mounted 2.6 m above the waterline, range 1.5 to 200 m, 360 rays. Rays are cast horizontally (independent of vessel roll and pitch) by ray-marching against the analytic terrain height field and testing analytic colliders (rotated boxes, cylinders, spheres). A range of0.0denotes no return. - TF. A dynamic
map -> base_linktransform, plus staticbase_link -> {gps_link, imu_link, lidar_link}transforms so TF consumers can resolve every sensor frame. Abase_link_stabframe (vehicle XY position, level orientation) is also published; RViz's chase camera targets it so that wave-induced roll, pitch, and heave do not shake the view.
# Drive forward at ~2.5 m/s
ros2 topic pub -r 10 /usv/cmd_vel geometry_msgs/msg/Twist '{linear: {x: 2.5}}'
# Forward with a turn to port (positive angular.z = counter-clockwise)
ros2 topic pub -r 10 /usv/cmd_vel geometry_msgs/msg/Twist '{linear: {x: 4.0}, angular: {z: 0.4}}'
# Drive the thrusters directly, [port, starboard], range -1..1
ros2 topic pub -r 10 /usv/thrusters std_msgs/msg/Float32MultiArray '{data: [0.9, 0.3]}'
# Reset the vehicle to its spawn point
ros2 topic pub --once /usv/reset std_msgs/msg/Empty '{}'Commands are subject to a 1.5 s safety timeout: if no command arrives within that window, the thrusters are zeroed. This is why the teleop node republishes continuously at 10 Hz.
Sensors: simulator --(NavSatFix/Imu/Odometry/LaserScan/TF)--> rosbridge --> ROS nodes
Commands: ROS nodes --(Twist / Float32MultiArray / Empty)------> rosbridge --> simulator
Mission: browser UI --(Path + String command)-----------------> mission_manager
mission_manager --(spawns)--> autonomous controller --(Twist)--> simulator
Autonomy is designed so that the route is never hard-coded: the operator draws it in the
browser and the ROS side follows it. There are two moving parts, the in-browser planner and the
mission_manager node.
Press M to enter the Mission Planner. The camera switches to a top-down view of the strait.
- Click on the water to drop numbered waypoints. A cyan route line connects them in order.
- Click an existing waypoint to remove it; drag to pan; scroll to zoom.
- Optionally tick "Loop route" to make the vehicle cycle the route continuously.
As the route changes, the planner publishes it as a nav_msgs/Path (in the map / ENU frame)
on /usv/mission/waypoints. This is picked up by the visualization node so the planned route
appears live in RViz as well. The relevant client code is
web/js/mission.js and web/js/ros.js.
Pressing Start Autonomous publishes the current route once more, then sends a JSON command
on /usv/mission/command:
{ "action": "start", "loop": false, "cruise": 6.0 }The mission_manager node (ros2_ws/src/usv_sim/usv_sim/mission_manager.py)
receives this and:
- Spawns the autonomous controller with the operator's waypoints passed as a parameter, e.g.
ros2 run usv_sim autonomous --ros-args -p waypoints:=[e1, n1, e2, n2, ...] -p loop:=false -p cruise_speed:=6.0. - Opens RViz2 with the bundled configuration if it is not already running.
- Publishes progress on
/usv/mission/status(running,idle,stopped,no_waypoints), which the planner panel displays.
Because a browser cannot spawn desktop processes, mission_manager is the component that
actually starts the controller and RViz on the operator's behalf. It spawns each child in its
own process group and, on Stop, signals the whole group so the controller (and the node it
launches) shuts down cleanly; it also publishes a zero Twist as a safety measure.
The controller (ros2_ws/src/usv_sim/usv_sim/autonomous_controller.py)
uses Line-of-Sight (LOS) guidance, the standard approach for marine path following. Rather
than steering at the next waypoint (which cuts corners and drifts off the intended track), it
steers toward the line segment between the previous and current waypoint.
For a segment from A to B, given the vehicle position:
path_angle = atan2(By - Ay, Bx - Ax)
cross_track = -(x - Ax) * sin(path_angle) + (y - Ay) * cos(path_angle)
along_track = (x - Ax) * cos(path_angle) + (y - Ay) * sin(path_angle)
desired_course = path_angle + atan2(-cross_track, lookahead) # lookahead = 45 m
The desired course drives the cross-track error to zero, so the vehicle converges onto the line and stays on it. Steering is a PD law on the heading error:
angular.z = kp_yaw * heading_error - kd_yaw * yaw_rate # kp_yaw = 1.0, kd_yaw = 0.55
The derivative term (damping on the measured yaw rate, taken from the odometry twist) suppresses the oscillation that a proportional-only controller produces on a fast, high-inertia hull. Forward speed is scaled down for large heading errors and large cross-track errors, so the vehicle slows into turns. A waypoint is considered reached when the vehicle is within tolerance of it or has passed it along the track, which prevents the controller from circling a point it cannot exactly hit.
Obstacle avoidance runs on top of guidance: the nearest return within a +/- 40-degree frontal
sector of the lidar, closer than avoid_distance, adds a steering bias away from the obstacle
and reduces speed.
The controller publishes geometry_msgs/Twist on /usv/cmd_vel (which flows back through
rosbridge to the simulator) and the active waypoint index on /usv/current_wp (used by RViz to
color the target waypoint). Tuning parameters (lookahead, kp_yaw, kd_yaw, cruise_speed,
wp_tolerance, avoid_distance, loop) are all ROS parameters.
Verified path-following accuracy on a multi-turn route is approximately 0.7 m average cross-track error (4.4 m maximum, at the sharpest corner).
source scripts/env.sh
ros2 launch usv_sim mission_planner.launch.py
# brings up rosbridge + monitor + viz + mission_manager
# then, in the browser: press M, draw a route, press Start Autonomousscripts/mission.sh wraps this command.
A self-contained autonomous mission with a built-in channel-centerline route is also available:
source scripts/env.sh
ros2 launch usv_sim autonomous_mission.launch.pyWith a simulator tab focused (and the editor and planner closed), the vehicle can be driven from the keyboard:
| Key | Action |
|---|---|
| W / S | forward / reverse thrust |
| A / D | port / starboard turn |
| C | cycle camera mode (follow / orbit / free) |
| E | toggle the environment editor |
| M | toggle the mission planner |
| R | reset the vehicle to spawn |
| H | show / hide the help panel |
Keyboard commands are overridden by ROS commands and vice versa; releasing the keys zeroes the manual thrust.
The teleop node offers keyboard control from a terminal instead, publishing to /usv/cmd_vel
at 10 Hz:
source scripts/env.sh
ros2 run usv_sim teleopPress E to open the scene editor. The scene is fully customizable and persists across sessions.
- Pick an object type from the palette and click on the water to place it.
- Click an object to select it; drag to move, Q/E to rotate, +/- to scale, Delete to remove.
- Save to the browser (localStorage), or download / upload the scene as JSON.
Object types include red and green channel buoys, a container ship, a small ship, a sailboat, a
tugboat, docks, a lighthouse, crates, a platform, and a custom GLB by URL. The starting
scene is defined in web/environment.json; its schema is:
{
"objects": [
{ "type": "buoy_red", "x": 44, "z": 1250, "yawDeg": 0, "scale": 1, "name": "optional label" },
{ "type": "custom_glb", "x": 0, "z": 500, "url": "https://.../model.glb", "targetLength": 30 }
]
}Coordinates are in simulator world meters (x = East, z = South, so ROS North = -z). Floating objects ride the waves; fixed objects (docks, lighthouses) sit on the terrain. Every object contributes a collider that the vehicle and the lidar both see.
scripts/rviz.sh
# or: source scripts/env.sh && ros2 launch usv_sim rviz.launch.pyThe bundled configuration (ros2_ws/src/usv_sim/rviz/usv.rviz)
shows:
- Odometry (USV) - live position and heading, with a hull box.
- Waypoints & Route - waypoint spheres (green = upcoming, orange = active target, gray = passed) with labels, and the route line.
- Traveled Path - the track the vehicle has actually followed.
- Lidar - the 360-degree scan returns.
- TF Frames and a 100 m grid.
The chase camera targets the base_link_stab frame, so the view follows the vehicle smoothly
without inheriting wave motion. A saved "Bird's Eye" view shows the whole map from above.
The Mission Planner opens RViz automatically when you press Start Autonomous, so a separate launch is usually unnecessary.
tekne_simulation/
web/ Browser client (served statically, no build)
index.html App shell, import map, loading splash
environment.json Default scene definition
js/
config.js Central configuration (physics, sensors, visuals, topics)
waves.js CPU Gerstner wave field
ocean.js GPU ocean surface (shader, same wave params)
terrain.js Stylized Bosphorus terrain, bridges, landmarks, colliders
physics.js Rigid-body USV dynamics
sensors.js Sensor synthesis + ENU/FLU conversions
ros.js rosbridge connection, publishers/subscribers, mission API
boat.js Vehicle visual model + wake
environment.js Scene object registry, placement, colliders
editor.js In-browser scene editor
mission.js Top-down mission planner
ui.js HUD (status, telemetry, help)
main.js Bootstrap, fixed-step loop, camera, input
vendor/ Three.js and roslib (vendored, no CDN)
ros2_ws/src/usv_sim/ ROS 2 (ament_python) package
usv_sim/
monitor.py Subscribes all sensors, prints rates and status
teleop_keyboard.py Terminal keyboard teleoperation
autonomous_controller.py LOS path-following controller
viz.py RViz helper: path, markers, stabilized camera frame
mission_manager.py Receives UI route + command, launches controller and RViz
launch/ Launch files (bringup, autonomous, rviz, mission_planner)
rviz/usv.rviz RViz configuration
scripts/ env, serve, build, rviz, mission, run_all, setup_rosbridge
"ROS offline" in the browser. Make sure rosbridge is running and reachable on port 9090.
The client reconnects automatically every 2 seconds.
rosbridge fails to start / serialization errors. A standalone bson pip package shadows
PyMongo's bson module and breaks rosbridge. Remove it: pip uninstall -y bson (keep PyMongo).
The vehicle position jumps around, or RViz shakes / drifts. Two simulator tabs are connected
to the same bridge and both are publishing odometry. Close all but one simulator tab. You can
confirm with ros2 topic echo /client_count (should read 1) and ros2 topic hz /usv/odom
(should read approximately 20 Hz, not 40).
Code changes do not appear. The browser is caching the ES modules. Hard-reload
(Ctrl+Shift+R). The provided serve_web.py sets no-store headers to avoid this.
Port already in use. Check for stale processes on 8080 (web) or 9090 (rosbridge):
ss -tlnp | grep -E '8080|9090'.
ROS 2 installed somewhere non-standard. scripts/env.sh sources ROS 2, rosbridge, and this
workspace. Edit the paths at the top of the script if your installations differ from the
defaults.