Vì sao bài này bắt đầu từ DDS low-level?
Khi mới học mô phỏng humanoid, rất dễ bắt đầu bằng một ví dụ ROS chung chung: load URDF, publish /joint_states, subscribe một topic command, rồi xem robot nhúc nhích trong viewer. Cách đó tốt để học middleware, nhưng chưa đủ nếu mục tiêu của bạn là viết controller có thể đi từ sim sang Unitree G1 thật. Unitree G1 không chỉ là một model MJCF có nhiều khớp. Nó có stack giao tiếp riêng dựa trên Unitree SDK2 DDS, với các message như LowCmd, LowState, SportModeState và, riêng G1, IMUState trên topic rt/secondary_imu.
Bài 1 trong series này dựng nền tảng đó. Chúng ta sẽ bám vào repo chính thức unitreerobotics/unitree_mujoco, đặc biệt là readme.md, simulate/config.yaml, simulate_python/config.py, simulate/src/main.cc và simulate_python/unitree_mujoco.py. Mục tiêu không phải là viết controller đi bộ ngay. Mục tiêu là hiểu simulator được nối vào DDS như thế nào, chọn đúng robot, robot_scene, domain_id, interface, bật/tắt use_joystick, và biết topic nào là dữ liệu thật để các bài sau ghi MCAP, mở Foxglove, plot tín hiệu và thiết kế PD/WBID.
Nếu bạn mới hoàn toàn với MuJoCo, nên đọc trước Bắt đầu với MuJoCo. Nếu bạn quan tâm đường dài sim-to-real cho G1, bài GR00T-VisualSim2Real cho G1 cho bức tranh rộng hơn về training policy và deploy lên humanoid.

Roadmap series
Series G1 MuJoCo: điều khiển, Foxglove và PlotJuggler gồm 6 bài, đi từ bring-up simulator đến controller và sim-to-real:
| Phần | Bài | Nội dung chính |
|---|---|---|
| 1 | Dựng G1 MuJoCo qua DDS low-level | Cấu hình Unitree MuJoCo, DDS domain/interface, joystick và topic/message thật |
| 2 | Ghi MCAP và mở trong Foxglove | Ghi LowState, SportModeState, IMU và command thành log có thể replay |
| 3 | PlotJuggler cho tín hiệu G1 | Vẽ joint position, velocity, torque, IMU, command tracking và delay |
| 4 | WBID và PD cho G1 trong MuJoCo | Thiết kế vòng điều khiển low-level, gain, saturation và kiểm tra ổn định |
| 5 | Upper-body IK cho G1 | Điều khiển tay/thân trên, mapping joint index và kiểm tra command an toàn |
| 6 | Checklist sim-to-real | Những điểm phải khớp giữa sim và robot thật: domain, network, gain, timing, safety |
Bạn có thể xem bài này như "đường ống nước" của toàn series. Nếu DDS topic sai, domain sai hoặc scene không đúng G1, mọi dashboard đẹp ở các bài sau đều chỉ là dữ liệu sai được vẽ đẹp.
Unitree MuJoCo khác gì một simulator ROS thông thường?
README của repo mô tả unitree_mujoco là simulator phát triển dựa trên Unitree SDK2 và MuJoCo. Nó không chỉ expose một API Python để đọc qpos, qvel. Nó dựng một bridge để chương trình viết bằng unitree_sdk2, unitree_ros2 hoặc unitree_sdk2_python có thể nói chuyện với simulator theo cùng phong cách như nói chuyện với robot thật. Repo có hai entry point:
| Thư mục | Vai trò | Khi nào dùng |
|---|---|---|
simulate |
Simulator C++ dựa trên unitree_sdk2 và MuJoCo |
Nên dùng khi muốn sát stack SDK2, hiệu năng tốt và topic G1 đầy đủ |
simulate_python |
Simulator Python dựa trên unitree_sdk2_python và package mujoco |
Nên dùng để đọc code nhanh, thử nghiệm, debug và teaching |
unitree_robots |
MJCF robot và scene được simulator load | Chọn g1/scene.xml, g1/scene_23dof.xml hoặc g1/scene_29dof.xml |
example |
Ví dụ điều khiển từ C++, Python và ROS2 | Dùng để hiểu pattern sim-to-real của Unitree |
Điểm quan trọng nhất: phiên bản hiện tại của README nói simulator chủ yếu hỗ trợ low-level development, dùng để kiểm chứng controller trước khi đi sang robot thật. Điều đó có nghĩa là bạn nên tư duy theo LowCmd và LowState, không phải theo một API high-level giả lập kiểu "go to x,y,z". Với G1, README cũng ghi rõ G1/H1-2 dùng unitree_hg IDL cho low-level communication, trong khi Go2/B2/H1/B2w/Go2w dùng unitree_go IDL. Nếu bạn copy test program của Go2 và chạy thẳng cho G1, bạn có thể load được scene nhưng message type không khớp.
Cấu hình C++: simulate/config.yaml
File C++ config hiện có các trường tối thiểu sau:
robot: "go2" # Robot name, "go2", "b2", "b2w", "h1", "go2w", "g1", "h2"
robot_scene: "scene.xml" # Robot scene, /unitree_robots/[robot]/scene.xml
domain_id: 1
interface: "lo"
use_joystick: 0
joystick_type: "xbox"
joystick_device: "/dev/input/js0"
joystick_bits: 16
print_scene_information: 1
enable_elastic_band: 0
Để dựng G1 trên máy local, cấu hình beginner nên bắt đầu như sau:
robot: "g1"
robot_scene: "scene.xml"
domain_id: 1
interface: "lo"
use_joystick: 0
joystick_type: "xbox"
joystick_device: "/dev/input/js0"
joystick_bits: 16
print_scene_information: 1
enable_elastic_band: 0
robot quyết định thư mục dưới unitree_robots. Nếu đặt robot: "g1", simulator sẽ tìm scene trong unitree_robots/g1/. robot_scene là file scene trong thư mục đó. Với G1, repo có scene.xml, scene_23dof.xml và scene_29dof.xml. scene.xml hiện include model g1_29dof.xml, thêm ground plane, height field, cầu thang và vật cản. Nếu bạn muốn thử mapping 23DOF, dùng scene_23dof.xml. Nếu muốn full 29DOF như nhiều bài upper-body/control, dùng scene_29dof.xml hoặc scene mặc định.
domain_id là DDS domain. README khuyến nghị phân biệt simulator với robot thật, vì robot thật thường dùng domain mặc định 0. Đây là quy tắc an toàn rất quan trọng. Khi làm local simulation, dùng domain_id: 1 giúp giảm khả năng controller thử nghiệm vô tình nói chuyện với robot thật trên cùng mạng. Khi deploy thật, bạn mới chuyển domain/interface theo network thật và theo checklist safety.
interface là network interface mà DDS dùng. Trong simulation local, đặt "lo" để dùng loopback. Controller chạy trong terminal khác cũng phải khởi tạo SDK2 với cùng domain và interface, ví dụ C++ dùng ChannelFactory::Instance()->Init(1, "lo"), Python dùng ChannelFactoryInitialize(1, "lo"). Nếu simulator dùng domain_id: 1, interface: "lo" nhưng script điều khiển dùng domain 0 hoặc interface eth0, hai tiến trình sẽ không thấy nhau.
use_joystick điều khiển việc simulator mô phỏng Unitree wireless controller bằng tay cầm Xbox hoặc Switch. Nếu không cắm tay cầm, đặt 0. README nói rõ khi không có gamepad thì use_joystick hoặc USE_JOYSTICK cần đặt 0. Đây là lỗi beginner gặp rất nhiều: simulator khởi động rồi treo hoặc báo lỗi joystick vì cấu hình mặc định trong một số file Python bật joystick.
Cấu hình Python: simulate_python/config.py
File Python tương đương:
ROBOT = "go2"
ROBOT_SCENE = "../unitree_robots/" + ROBOT + "/scene.xml"
DOMAIN_ID = 1
INTERFACE = "lo"
USE_JOYSTICK = 1
JOYSTICK_TYPE = "xbox"
JOYSTICK_DEVICE = 0
PRINT_SCENE_INFORMATION = True
ENABLE_ELASTIC_BAND = False
SIMULATE_DT = 0.005
VIEWER_DT = 0.02
Cho G1 beginner, sửa thành:
ROBOT = "g1"
ROBOT_SCENE = "../unitree_robots/" + ROBOT + "/scene.xml"
DOMAIN_ID = 1
INTERFACE = "lo"
USE_JOYSTICK = 0
JOYSTICK_TYPE = "xbox"
JOYSTICK_DEVICE = 0
PRINT_SCENE_INFORMATION = True
ENABLE_ELASTIC_BAND = False
SIMULATE_DT = 0.005
VIEWER_DT = 0.02
SIMULATE_DT là timestep MuJoCo. Trong source Python, mj_model.opt.timestep = config.SIMULATE_DT, sau đó thread physics gọi mujoco.mj_step(mj_model, mj_data) đều đặn. Comment trong config nhắc rằng timestep cần lớn hơn thời gian chạy một lần viewer.sync() nếu bạn không muốn viewer làm nghẽn mô phỏng. VIEWER_DT = 0.02 nghĩa là viewer sync khoảng 50 FPS. Khi mới học, đừng giảm timestep quá mạnh chỉ vì muốn "500 Hz đẹp hơn". Nếu máy không kịp render hoặc Python thread bị giật, telemetry bạn ghi ở bài 2 và bài 3 sẽ phản ánh timing xấu.
PRINT_SCENE_INFORMATION = True rất nên bật ở lần chạy đầu. Bridge sẽ in link, joint, actuator và sensor index. Với G1, danh sách này giúp bạn nối từ tên joint trong MJCF sang index trong LowCmd.motor_cmd và LowState.motor_state. Repo cũng có unitree_robots/g1/g1_joint_index_dds.md, liệt kê thứ tự motor cho bản 23DOF và 29DOF. Ví dụ bản 29DOF bắt đầu từ L_LEG_HIP_PITCH, L_LEG_HIP_ROLL, L_LEG_HIP_YAW, L_LEG_KNEE, rồi đến ankle, chân phải, waist và hai tay. Đừng hardcode index theo trí nhớ nếu bạn đổi scene.
Entry point C++ làm gì?
Trong simulate/src/main.cc, chương trình đọc config, nhận override command line, resolve scene tương đối thành đường dẫn dưới unitree_robots/<robot>/<scene>, rồi chạy hai luồng chính: physics/viewer và Unitree SDK2 bridge. Hàm helper trong param.h cho phép override nhanh:
./unitree_mujoco -r g1 -s scene.xml -i 1 -n lo
Các flag có ý nghĩa:
| Flag | Tương đương config | Ví dụ |
|---|---|---|
-r |
robot |
-r g1 |
-s |
robot_scene |
-s scene.xml |
-i |
domain_id |
-i 1 |
-n |
interface |
-n lo |
Điểm đáng chú ý trong main.cc là thread bridge gọi:
unitree::robot::ChannelFactory::Instance()->Init(
param::config.domain_id,
param::config.interface
);
Sau đó source chọn bridge theo robot. Nếu robot == "g1", nó tạo G1Bridge; nếu không, ví dụ Go2, nó tạo bridge khác. Đây là lý do robot: "g1" không chỉ ảnh hưởng tới mesh hoặc scene. Nó còn quyết định message wrapper và topic phụ như secondary IMU.
Thread physics gọi mj_step(m, d) để tiến mô phỏng. Bridge đọc sensor từ mj_data_->sensordata, publish state ra DDS, đồng thời nhận LowCmd từ DDS rồi ghi vào mj_data_->ctrl. Công thức low-level trong bridge có dạng:
ctrl[i] = tau
+ kp * (q_des - q_measured)
+ kd * (dq_des - dq_measured)
Vì vậy LowCmd không chỉ là torque thuần. Bạn có thể gửi feedforward torque tau, target position q, target velocity dq, và gain kp, kd. Bài 4 sẽ đi sâu vào chuyện chọn gain và saturation. Ở bài này, bạn chỉ cần nhớ: simulator đang thực thi command theo semantics low-level của Unitree SDK2, không phải topic ROS tự chế.
Entry point Python làm gì?
simulate_python/unitree_mujoco.py giúp beginner đọc luồng dễ hơn. File này load model bằng:
mj_model = mujoco.MjModel.from_xml_path(config.ROBOT_SCENE)
mj_data = mujoco.MjData(mj_model)
Sau đó launch viewer, đặt timestep, rồi tạo hai thread:
viewer_thread = Thread(target=PhysicsViewerThread)
sim_thread = Thread(target=SimulationThread)
Trong SimulationThread, code gọi:
ChannelFactoryInitialize(config.DOMAIN_ID, config.INTERFACE)
unitree = UnitreeSdk2Bridge(mj_model, mj_data)
Nếu USE_JOYSTICK bật, bridge setup joystick. Nếu PRINT_SCENE_INFORMATION bật, bridge in scene information. Vòng lặp chính lock dữ liệu, gọi mujoco.mj_step, rồi sleep phần thời gian còn lại để bám timestep. Viewer thread lock cùng mutex, gọi viewer.sync(), rồi sleep theo VIEWER_DT. Đây là thiết kế tối giản nhưng đủ để hiểu timing: physics và viewer không phải cùng một việc. Khi telemetry bị giật, hãy kiểm tra cả timestep physics lẫn viewer sync.
Một ghi chú quan trọng về phiên bản: README liệt kê IMUState tại rt/secondary_imu cho G1. Trong source C++ hiện tại, G1Bridge tạo IMUState_t("rt/secondary_imu") và publish quaternion, RPY, gyroscope, accelerometer từ sensor secondary_imu_*. Trong source Python bridge mà bài này kiểm tra, các topic low/high/wireless thể hiện rõ hơn, còn secondary IMU có thể phụ thuộc phiên bản branch. Nếu bài sau bạn cần rt/secondary_imu đầy đủ để ghi MCAP, hãy ưu tiên C++ simulator hoặc kiểm tra Python bridge của checkout đang dùng.
Những topic và message thật cần nhớ
README chính thức liệt kê các Unitree SDK2 message simulator hỗ trợ:
| Message | Topic thường dùng | Ý nghĩa |
|---|---|---|
LowCmd |
rt/lowcmd |
Lệnh điều khiển motor: q, dq, tau, kp, kd, mode |
LowState |
rt/lowstate |
Trạng thái motor: position, velocity, estimated torque, IMU thân |
SportModeState |
rt/sportmodestate |
Vị trí và vận tốc robot, hữu ích để phân tích controller trong sim |
IMUState |
rt/secondary_imu |
IMU phụ cho G1, gồm quaternion/RPY/gyro/accelerometer |
WirelessController |
rt/wirelesscontroller |
Trạng thái tay cầm mô phỏng khi bật joystick |
Có hai điểm dễ nhầm. Thứ nhất, SportModeState trong robot thật có thể không đọc được sau khi tắt built-in motion control service, nhưng simulator vẫn giữ message này để bạn phân tích vị trí/vận tốc controller. Vì vậy dùng SportModeState trong dashboard là hợp lý cho debugging sim, nhưng đừng xây controller thật phụ thuộc tuyệt đối vào nó nếu mode robot thật không cung cấp.
Thứ hai, G1 dùng unitree_hg IDL cho low-level. Nếu bạn viết Python subscriber/publisher, import phải đúng family message. Repo Python bridge tự chọn unitree_hg khi số motor lớn hơn ngưỡng Go IDL, nhưng test script tự viết của bạn vẫn có thể sai. Triệu chứng thường là topic có vẻ đúng tên nhưng callback không chạy, hoặc DDS discovery thấy participant mà deserialize không được.
Bring-up từng bước cho beginner
Luồng thao tác nên đi chậm và kiểm tra sau mỗi bước:
- Cài dependency theo README:
unitree_sdk2cho C++,unitree_sdk2_pythoncho Python, MuJoCo và joystick package nếu cần. Nếu Python báo không tìm thấy CycloneDDS, làm theo hướng dẫn củaunitree_sdk2_pythonđể đặtCYCLONEDDS_HOMEhoặcCMAKE_PREFIX_PATH. - Chọn một simulator trước. Nếu mục tiêu là học source nhanh, chạy
simulate_python. Nếu mục tiêu là topic G1 sát SDK2 nhất, build và chạysimulate. - Sửa config sang
robot: "g1",robot_scene: "scene.xml",domain_id: 1,interface: "lo",use_joystick: 0. - Chạy simulator và xác nhận viewer mở G1. Nếu bật
print_scene_information, lưu ý số actuator và sensor được in ra. - Mở terminal thứ hai, chạy một subscriber hoặc test program với đúng domain/interface. Đừng dùng domain
0nếu simulator đang ở1. - Chỉ sau khi đọc được
LowState, hãy thử publishLowCmdnhỏ, ví dụ gain thấp hoặc torque kiểm tra từng joint trong môi trường an toàn.
Sơ đồ tối giản:
Terminal A
unitree_mujoco / unitree_mujoco.py
DOMAIN_ID=1, INTERFACE=lo
|
| DDS topics
v
Terminal B
logger / controller / Foxglove bridge
DOMAIN_ID=1, INTERFACE=lo
rt/lowcmd controller -> simulator
rt/lowstate simulator -> logger/controller
rt/sportmodestate simulator -> logger
rt/secondary_imu G1 bridge -> logger/controller
Nếu không thấy dữ liệu, checklist đầu tiên là: cùng domain chưa, cùng interface chưa, message IDL đúng chưa, joystick có đang bị bật khi không cắm tay cầm không, scene có đúng G1 không, và controller có publish vào rt/lowcmd chứ không phải topic ROS tự đặt tên không.
Chọn robot_scene cho bài sau
unitree_robots/g1/scene.xml hiện include g1_29dof.xml và thêm nhiều terrain: ground plane, obstacle, stairs, rough boxes và height field. Điều này tốt cho thử nghiệm locomotion và telemetry vì SportModeState, IMU và joint load thay đổi rõ khi robot tương tác với mặt đất không phẳng. Nhưng khi bạn mới kiểm tra PD joint-level, scene phức tạp có thể làm khó debug. Một workflow hợp lý:
| Mục tiêu | Scene gợi ý |
|---|---|
| Kiểm tra DDS, topic, subscriber | scene.xml |
| Kiểm tra joint index 23DOF | scene_23dof.xml |
| Làm upper-body 29DOF | scene_29dof.xml hoặc scene.xml |
| Plot tín hiệu trên terrain | scene.xml |

Với series này, mặc định chúng ta dùng g1 và scene.xml để có full telemetry. Khi bài 5 đi vào upper-body IK, ta sẽ quay lại joint index 29DOF và cách map command cho vai, khuỷu, cổ tay.
Kết luận
Điều quan trọng nhất của bài 1 là thay đổi cách nhìn: G1 MuJoCo không phải là một robot 3D đứng trong viewer, mà là một endpoint DDS low-level giả lập robot thật. robot và robot_scene chọn đúng model; domain_id và interface quyết định các tiến trình có nhìn thấy nhau; use_joystick quyết định có publish wireless controller giả lập hay không; LowCmd, LowState, SportModeState và IMUState là các message bạn sẽ ghi, vẽ và điều khiển trong các bài sau.
Ở bài 2, chúng ta sẽ dùng chính các topic này để ghi MCAP và mở trong Foxglove. Đến bài 3, cùng dữ liệu đó sẽ được đưa vào PlotJuggler để kiểm tra tracking, delay và nhiễu. Khi nền DDS đã đúng, phần visualization và control phía sau sẽ đỡ mơ hồ hơn rất nhiều.


