G1 PPO 보행 실험 v161의 15초 trace를 정리하다가 서로 양립하기 어려워 보이는 숫자를 만났습니다. 로봇 기준 전진 속도는 평균 +0.590m/s였는데, 시뮬레이션 월드 기준 x 속도는 평균 -0.207m/s였습니다. 평균 x 변위도 -0.667m, 명령 방향과 로봇 전방의 내적은 -0.249였습니다. 그런데 넘어짐은 없었고 6,000개 관측 행 가운데 4,351개는 한쪽 발만 닿은 single-support였습니다.
처음에는 속도 로그 하나가 잘못 기록됐다고 생각하기 쉽습니다. 실제 원인은 더 단순하고 더 위험했습니다. 로봇은 자신이 바라보는 방향으로는 앞으로 걷고 있었지만, 몸이 월드의 명령 방향 반대로 돌아가 있었습니다. body-frame vx만 보면 성공이고 world-frame displacement로 보면 실패인 정책이었습니다.
이 문제를 겪고 나서 좌표계는 시각화할 때 붙이는 표기가 아니라 task contract의 일부라는 점을 분명히 보게 됐습니다. 관측은 어느 좌표계의 값인지, 명령은 어디를 기준으로 정의됐는지, reward와 평가는 같은 방향을 말하는지 정하지 않으면 각각의 수식이 맞아도 전체 학습은 다른 문제를 풀 수 있습니다.

좌표계는 물체가 아니라 숫자를 해석하는 축입니다
속도 벡터 자체는 하나입니다. 좌표계를 바꾼다고 로봇의 실제 움직임이 바뀌는 것은 아닙니다. 다만 그 벡터를 분해하는 기준 축이 바뀝니다. 방 안에 고정된 동서남북 축으로 속도를 적을 수도 있고, 로봇의 가슴에 붙인 앞·왼쪽·위쪽 축으로 적을 수도 있습니다.
로봇이 월드 +X 방향을 보고 있을 때는 두 표현의 전진 성분이 거의 같습니다. 하지만 로봇이 yaw 방향으로 180도 돌아서 월드 -X를 보고 있다면 로봇 기준 +X는 월드 기준 -X가 됩니다. 이 상태에서 로봇이 몸 앞쪽으로 0.6m/s 움직이면 body vx는 양수이고 world vx는 음수입니다. 두 로그는 모순이 아니라 같은 움직임을 다른 축으로 쓴 결과입니다.
Isaac Sim 5.1의 기본 월드 축은 오른손 좌표계이며 +Z가 위, +X가 전방입니다. 여기서 ‘전방’은 시뮬레이션 월드에 고정된 기준이지, 회전하는 G1의 가슴이 향하는 방향을 뜻하지 않습니다. 카메라·USD·ROS가 각기 다른 축 관례를 사용할 수 있다는 점도 별개의 문제입니다. 이 글에서는 현재 보행 환경이 사용하는 시뮬레이션 월드 축과 G1 base 축만 다룹니다.
월드·base·link·joint frame은 서로 맡은 일이 다릅니다
| 좌표 기준 | 어디에 붙어 있는가 | 현재 G1 코드에서 주로 쓰는 값 | 용도 |
|---|---|---|---|
| world frame | 시뮬레이션 공간에 고정 | root_pos_w, root_lin_vel_w |
실제 이동 경로, 명령 방향 진행량, 환경 내 위치 평가 |
| base/body frame | G1의 root body와 함께 회전 | base_lin_vel_b, projected_gravity_b |
로봇 기준 전후·좌우 속도와 몸체 기울기 관측 |
| link frame | 허벅지·종아리·발 같은 각 rigid body에 부착 | link pose, sole 위치·접촉 위치 | 발 위치, 센서 방향, 링크 사이 transform 계산 |
| joint frame/axis | 부모·자식 link의 연결부에 정의 | 29개 관절의 joint_pos, joint_vel |
정해진 회전축을 따른 관절각과 관절속도 표현 |
joint position 29개를 3차원 위치 벡터 29개로 이해하면 안 됩니다. 각 revolute joint는 asset에서 정한 축을 중심으로 얼마만큼 회전했는지를 하나의 스칼라로 기록합니다. 반면 발바닥 위치는 월드 또는 base 같은 3차원 frame에서 표현되는 벡터입니다. URDF와 USD의 역할에서 joint axis와 link transform을 실행 계약으로 구분한 이유가 여기 있습니다.
현재 G1 관측은 월드 상태를 그대로 policy에 넣지 않습니다
origin/main@ee4d96e의 sim/envs/g1_walk/g1_walk_env.py를 보면 simulator에서 받은 root 속도는 먼저 world frame 값입니다. 환경은 root quaternion을 이용해 선속도, 각속도, 중력 방향을 G1 base frame으로 회전한 뒤 관측 벡터에 넣습니다.
base_quat = robot.data.root_quat_w
base_lin_vel_w = robot.data.root_lin_vel_w
base_ang_vel_w = robot.data.root_ang_vel_w
base_lin_vel_b = quat_apply_inverse(base_quat, base_lin_vel_w)
base_ang_vel_b = quat_apply_inverse(base_quat, base_ang_vel_w)
projected_gravity_b = quat_apply_inverse(base_quat, gravity_w)
그 결과 73차원 관측의 앞부분은 base-frame 선속도 3개, base-frame 각속도 3개, base-frame으로 투영한 중력 방향 3개가 됩니다. 그 뒤에 속도 명령, gait phase, target side, 29개 관절 위치 offset과 29개 관절속도가 이어집니다. 관측·행동·보상 전체 계약은 G1 강화학습 환경 읽기에서 따로 정리했고, 여기서는 그중 frame 변환만 좁혀서 봅니다.
Isaac Lab 2.1.1의 data API도 suffix를 구분합니다. _w는 simulation world frame, _b는 base 또는 각 link 기준 표현입니다. 이름이 길어 보여도 이 suffix가 없으면 속도 성분을 읽을 방법이 없습니다.
quat_apply_inverse는 월드 벡터를 로봇 축으로 가져옵니다
root_quat_w는 G1 base가 월드에서 어떤 방향을 향하는지 나타냅니다. 현재 프로젝트가 기준으로 삼는 Isaac Lab 2.x data API의 quaternion 순서는 (w, x, y, z)입니다. 라이브러리나 버전이 바뀌면 순서가 다를 수 있으므로 배열 네 개를 보고 추측해서는 안 됩니다.
quat_apply(q, v_body)는 body 축에서 쓴 벡터를 world 축으로 보내는 데 쓰고, quat_apply_inverse(q, v_world)는 반대로 world 벡터를 body 축으로 가져오는 데 씁니다. 예를 들어 base가 yaw +90도로 돌아갔다고 하겠습니다. body 전방 벡터 [1, 0, 0]를 월드로 보내면 [0, 1, 0]이 됩니다. 같은 월드 벡터 [0, 1, 0]을 inverse rotation으로 base에 가져오면 다시 [1, 0, 0]입니다.
v_world = quat_apply(root_quat_w, v_body)
v_body = quat_apply_inverse(root_quat_w, v_world)
여기서 quaternion 값을 뒤집거나 성분 순서를 바꾸는 식의 수동 처리는 피했습니다. quaternion inverse와 벡터 회전을 library 함수에 맡기면 batch 환경 전체에 같은 convention을 적용할 수 있고, 코드에서 변환 방향도 드러납니다.
projected gravity는 로봇이 어느 쪽으로 기울었는지 알려줍니다
월드의 중력 방향은 보통 [0, 0, -1]로 고정돼 있습니다. 이 벡터를 base frame으로 가져온 것이 projected_gravity_b입니다. 로봇이 똑바로 서 있으면 base에서 중력은 거의 아래쪽을 가리킵니다. 앞으로 숙이면 base x 성분이, 옆으로 기울면 base y 성분이 커집니다.
이 표현은 policy가 roll·pitch·yaw 각 자체를 외우지 않고도 몸체가 중력에 대해 어떻게 기울었는지 알게 합니다. 현재 reward와 termination에서도 projected gravity의 x·y 성분으로 tilt를 계산합니다. 다만 이것이 모든 orientation 정보를 대신하는 것은 아닙니다. 중력 축 주위의 회전인 yaw는 중력 벡터만으로 구분할 수 없기 때문입니다. 똑바로 서서 180도 뒤를 보는 로봇과 정면을 보는 로봇은 projected gravity가 거의 같습니다.
body-frame 속도 reward만으로는 ‘어디로’ 걷는지 보장할 수 없었습니다
현재 velocity tracking은 명령 속도와 base_lin_vel_b를 비교합니다. 이 선택은 합리적입니다. ‘로봇 기준으로 전진 0.5m/s’라는 명령은 로봇이 어느 월드 위치에 있든 같은 observation/reward 의미를 갖고, policy도 임의의 월드 방위를 외울 필요가 없습니다.
하지만 명령이 월드 +X 진행까지 포함한다면 body-frame tracking만으로는 조건이 부족합니다. G1이 180도 회전한 뒤 자기 앞쪽으로 걸어도 body x 속도는 명령을 만족합니다. projected gravity도 똑바로 서 있기만 하면 정상입니다. policy 입장에서는 reward가 말하지 않은 월드 방향을 지킬 이유가 없습니다.
이것은 PPO가 ‘속임수’를 쓴 것이 아니라 환경이 정의한 목적함수를 정확히 최적화한 결과입니다. reward hacking을 더 일반적인 관점에서 다룬 글은 reward가 높아도 걷지 않는 이유에 정리했습니다. 이번 사례의 구체적인 빈틈은 frame 사이의 의미 불일치였습니다.
그래서 progress와 heading은 world frame에서 따로 계산합니다
현재 코드는 body-frame velocity tracking과 별도로 월드 방향 진행량을 계산합니다. 명령의 xy 방향을 단위벡터 cmd_dir로 만들고, 월드 선속도를 그 방향에 투영합니다. 로봇의 body +X도 월드로 회전해 명령 방향과 내적합니다.
cmd_dir = cmd_xy / norm(cmd_xy)
progress_vel = dot(base_lin_vel_w[:, :2], cmd_dir)
forward_body = [1, 0, 0]
forward_w = quat_apply(base_quat, forward_body)
heading_dot = dot(forward_w[:, :2], cmd_dir)
progress_vel이 음수면 명령 반대로 이동하는 중입니다. heading_dot은 1에 가까울수록 로봇 전방과 명령 방향이 나란하고, 0이면 수직, 음수면 반대쪽을 향합니다. v161의 평균 heading dot -0.249는 단순한 좌우 흔들림이 아니라 상당 구간에서 몸의 전방이 명령과 반대쪽 반평면을 향했다는 신호입니다.
중요한 점은 body와 world 중 하나가 항상 옳다는 것이 아닙니다. body frame은 보행 동역학을 일반화하기 좋고, world frame은 목적지와 진행 방향을 평가하기 좋습니다. 각 metric이 답해야 하는 질문에 맞는 frame을 써야 합니다.
v91에서 heading reward를 올렸더니 이번에는 미끄러졌습니다
이 문제는 v161에서 처음 생긴 것이 아닙니다. v91 nominal 평가에서는 15초 동안 넘어지지 않았지만 x 변위가 -1.409m였습니다. body-frame 전진 속도는 양수인 채 yaw가 0.79rad에서 1.54, 2.62를 지나 -2.46rad까지 돌아갔습니다. 동시에 양발 접촉이 계속돼 실제 걸음보다 double-support sliding에 가까웠습니다.
다음 v92에서는 heading alignment 계수를 0.6에서 2.0으로 올렸습니다. slow command에서 변위는 +4.748m까지 회복되고 velocity error도 0.190으로 낮아졌습니다. 그러나 nominal command는 여전히 -0.430m였고, slow trace는 매 snapshot에서 양발이 모두 닿아 있었습니다. 방향을 바로잡았지만 ‘걷기’ 대신 ‘양발을 붙인 채 미끄러지기’라는 다른 우회로가 남았습니다.
이 시행착오는 좌표계 오류를 찾았다고 바로 보행 문제가 끝나지 않는다는 것을 보여줍니다. heading reward 하나를 키우면 방향 지표는 좋아질 수 있지만 contact sequence, single-support, 발 clearance 같은 gait 조건은 따로 검증해야 합니다.
v161은 보행 모양이 좋아도 frame 검증에서 탈락할 수 있음을 보여줬습니다
v161은 action bound를 넓힌 뒤 얻은 reference 결과였습니다. 15초 평가에서 slow와 nominal 모두 fall rate 0%였고, nominal trace에서는 single-support가 4,351/6,000행으로 나타났습니다. 좌우 foot-off 횟수도 2,140회와 2,211회로 비슷했습니다. 이전의 double-support 고착에 비하면 분명 진전이었습니다.
그러나 8개 환경 trace의 평균 body vx는 +0.590m/s, 평균 world vx는 -0.207m/s, 평균 heading dot은 -0.249, 평균 x 변위는 -0.667m였습니다. 개별 환경의 x 변위도 -4.503m에서 +1.518m까지 크게 갈렸습니다. 로봇은 몸 기준 앞으로 걷는 동작을 만들었지만 명령이 뜻한 월드 전진은 안정적으로 수행하지 못했습니다.
그래서 v161은 router에 연결할 checkpoint로 승인하지 않았습니다. ‘넘어지지 않는다’, ‘single-support가 나온다’, ‘좌우 대칭이 좋아졌다’는 각각 필요한 증거지만, 명령 방향 task의 성공을 단독으로 증명하지는 못합니다. 이 결정은 보행 정책 평가 기준에서 말한 단일 score 대신 행동 계약을 분리해 확인하는 방식과도 이어집니다.
world penalty를 추가하는 것만으로는 안정성과 방향을 동시에 얻지 못했습니다
v162에서는 약한 heading-misalignment 항을 추가했습니다. slow command 변위는 +4.533m였지만 nominal은 -0.852m였습니다. 8개 환경 평균 world vx와 heading dot은 거의 0까지 왔지만, 평균 lateral displacement가 1.678m이고 최대 9.295m까지 벌어졌습니다. 뒤로 가는 현상이 약해진 대신 옆으로 빠지는 해가 남은 셈입니다.
v163에서 backward penalty를 더 강하게 넣자 이번에는 생존 시간이 약 1초 수준으로 무너졌습니다. 방향 제약을 강하게 거는 동안 이미 어렵게 얻은 균형 정책이 깨졌습니다. 따라서 현재 결론은 ‘world reward가 부족하니 계수만 계속 키운다’가 아닙니다. 초기 자세, heading curriculum, command sampling, contact·swing 보상과 안정성 curriculum을 함께 봐야 합니다.
frame 문제를 재현할 때는 네 종류의 신호를 같은 시간축에 기록합니다
화면에서 로봇을 보기만 하면 카메라 각도 때문에 전진·후진을 잘못 판단하기 쉽습니다. 반대로 scalar reward만 보면 어떤 frame에서 잘못됐는지 알 수 없습니다. 다음 신호를 같은 control step에 묶으면 원인이 빠르게 좁혀집니다.
- 월드 상태:
root_pos_w,root_lin_vel_w, 시작점 대비dx·dy - 방향 상태:
root_quat_w,forward_w,cmd_dir,heading_dot - 로봇 기준 상태:
base_lin_vel_b,base_ang_vel_b,projected_gravity_b - 보행 상태: 좌우 contact, single/double support, sole height, termination cause
여기에 변환 전후의 벡터 norm이 같은지도 확인합니다. quaternion rotation은 방향만 바꾸므로 정상적인 단위 quaternion이라면 ||v_world||와 ||v_body||가 같아야 합니다. norm까지 달라지면 frame 의미가 아니라 quaternion normalization이나 데이터 slicing을 의심해야 합니다.
코드 변경 전 90도 회전 단위검사부터 통과시킵니다
복잡한 PPO rollout보다 먼저 할 수 있는 검사가 있습니다. base quaternion을 yaw 0도, +90도, 180도로 고정하고, 알려진 world 벡터를 inverse rotation한 결과를 확인하는 것입니다.
| base yaw | 월드에서 로봇 전방 | world velocity | 예상 body velocity |
|---|---|---|---|
| 0° | +X | [1, 0, 0] | [1, 0, 0] |
| +90° | +Y | [0, 1, 0] | [1, 0, 0] |
| 180° | -X | [-1, 0, 0] | [1, 0, 0] |
이 검사는 quaternion 순서, 회전 방향, forward-axis convention을 한 번에 드러냅니다. 그 뒤 실제 rollout에서 forward_w를 Isaac Sim 화면의 로봇 가슴 방향과 겹쳐 그리면 asset의 root frame이 우리가 생각한 전방과 같은지도 확인할 수 있습니다. 수학 함수가 맞아도 USD root frame이 180도 돌아가 있으면 task 의미는 다시 틀어집니다.
현재 확인된 것과 아직 승인하지 않은 것을 구분합니다
| 상태 | 현재 근거 | 아직 말할 수 없는 것 |
|---|---|---|
| Implemented | world 속도를 quaternion inverse로 base frame에 변환하고, world progress·heading을 별도로 계산 | 모든 sensor·controller interface의 frame이 자동으로 일치한다는 뜻은 아님 |
| Executed | v91·v92, v161·v162·v163 PPO 평가와 8-env trace 실행 | reward 계수 하나로 방향 문제가 해결됐다는 뜻은 아님 |
| Observed | v161에서 body vx 양수, world vx·heading dot·평균 변위 음수 | 로그 오류 없이 모든 환경이 정확히 같은 실패 원인을 가졌다고 단정할 수는 없음 |
| Accepted | frame별 metric을 분리해 평가해야 한다는 진단 규칙 | 현재 승인된 G1 보행 checkpoint는 없음 |
| Deployed | 실물 G1 또는 router runtime 배포 기록 없음 | 실물 좌표계·센서 extrinsic·통신 frame 일치 성능을 주장할 수 없음 |
다음 구현 단계는 reward 계수를 다시 만지기 전에 frame-consistency 검사를 자동화하는 것입니다. yaw 0·90·180도 synthetic case, quaternion norm, 변환 전후 vector norm, forward_w와 asset 시각축을 먼저 고정합니다. 그리고 다음 PPO evaluation에서는 body velocity, world progress, heading, contact state를 하나의 trace로 저장합니다.
좌표계가 다른 것은 문제가 아닙니다. 어떤 숫자가 어느 좌표계인지 숨겨진 채 서로 비교되는 것이 문제입니다. v161은 몸 기준으로는 앞으로 걸었고 월드 기준으로는 뒤로 갔습니다. 이 한 문장을 두 개의 정확한 로그로 설명할 수 있게 된 뒤에야, reward 문제와 보행 문제를 같은 것으로 착각하지 않게 됐습니다.
함께 읽기: G1의 관측·행동·보상 · PPO action이 관절 토크가 되는 과정 · reward hacking 진단 · G1 sim-to-real 검증 · Isaac Sim과 Isaac Lab의 역할
공식 문서: Isaac Sim 5.1 좌표·단위 convention · Isaac Lab 2.1.1 asset data API · Isaac Lab 2.1.1 math utilities