flowchart TD RL["<b>rsl_rl</b><br/>PPO 알고리즘 · 롤아웃 · 신경망 갱신"] LAB["<b>Isaac Lab</b><br/>강화학습 환경 · 관측/보상/종료 · 지형 · 센서"] SIM["<b>Isaac Sim</b> (Omniverse)<br/>씬 · 렌더링 · USD 에셋"] PX["<b>PhysX</b><br/>GPU 물리 엔진 — 강체·접촉·관절"] RL -->|"행동 12"| LAB LAB -->|"관측·보상"| RL LAB --> SIM --> PX classDef rl fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px; classDef lab fill:#fff3e0,stroke:#f57c00,stroke-width:2px; classDef sim fill:#e3f2fd,stroke:#1976d2; classDef px fill:#eceff1,stroke:#546e7a; class RL rl; class LAB lab; class SIM sim; class PX px;
Isaac Sim & Isaac Lab 시작하기 — 시뮬레이터를 손으로 만져보기
보지 않고 걷기 — CENet + 비대칭 PPO · 0차시
강의 로드맵
| 회차 | 주제 |
|---|---|
| 0차시 (오늘) | Isaac Sim & Isaac Lab 시작하기 — 시뮬레이터를 손으로 만져보기 |
| 1차시 | 4족 보행 로봇 & Isaac Lab — 관측을 무엇으로 채울 것인가 |
| 2차시 | PPO 설계 — 비대칭 actor-critic과 탐험의 평형점 |
| 3차시 | 왜 VAE인가 — 히스토리에서 속도와 지형을 뽑아내기 |
| 4차시 | 학습 루프 통합 — CENet과 PPO를 한 반복에 묶기 |
| 5차시 | 실험 설계와 결과 해석 — 잠재가 죽었는지 어떻게 아는가 |
1차시부터는 코드를 바로 읽기 시작합니다. 그런데 Isaac Sim / Isaac Lab 을 처음 보면 코드보다 그 아래 깔린 것들이 더 낯섭니다 — 왜 import 를 파일 중간에 하는지, num_envs=4096 이 무슨 뜻인지, decimation=4 는 왜 있는지.
이 회차는 그걸 먼저 정리합니다. 그리고 직접 쳐 보는 학습지 5장으로 끝냅니다. 읽기만 하면 안 붙습니다.
- Isaac Sim · Isaac Lab · rsl_rl 세 층이 각각 무엇을 하는지 구분한다.
- Isaac Lab 스크립트의 3단 구조와 시간 축(200 Hz 물리 / 50 Hz 정책)을 이해한다.
- 학습지 5장으로 씬·로봇·PD·지형·센서를 직접 만들어 본다.
설치는 다루지 않습니다. Isaac Sim 6.0 + Isaac Lab 이 이미 돌아간다고 가정합니다 (설치는 저장소의
setup.qmd참고). 여기서는 설치된 다음에 무엇을 알아야 하는지만 봅니다.
1. 세 개의 층 — 누가 무엇을 하나
가장 먼저 헷갈리는 지점입니다. 이름이 비슷한 것이 셋 있고, 역할이 전부 다릅니다.
| 층 | 하는 일 | 이 시리즈에서 |
|---|---|---|
| PhysX | 물리 계산. 관절·접촉·마찰. GPU에서 병렬로 돈다 | 직접 만질 일 없음 |
| Isaac Sim | 씬을 만들고 에셋(USD)을 올린다. 화면에 그린다 | 로봇 모델을 불러오는 정도 |
| Isaac Lab | 그 위에 강화학습 환경을 얹는다 — 관측·보상·종료·리셋 | 1차시의 주 무대 |
| rsl_rl | PPO 학습기. 환경은 모르고 숫자만 주고받는다 | 2차시의 주 무대 |
한 문장으로 — PhysX가 몸을 움직이고, Isaac Lab이 그걸 관측·보상이라는 숫자로 번역하고, rsl_rl이 그 숫자로 신경망을 고칩니다. 이 시리즈의 4차시는 이 번역 계층을 직접 뜯어고치는 이야기입니다.
2. 왜 GPU 시뮬레이터인가 — 4096이라는 숫자
이 프로젝트는 로봇을 4096마리 동시에 굴립니다. 처음 보면 과해 보이는데, 계산해 보면 그게 아니라 필수입니다.
PPO는 경험을 모아서 학습합니다. 한 번 갱신하는 데 쓰는 경험량은:
\text{샘플 수} = \underbrace{4096}_{\text{환경 수}} \times \underbrace{24}_{\text{롤아웃 스텝}} \approx 98{,}000 \;\text{스텝}
정책 주기가 50 Hz 이므로, 이건 로봇 한 마리 기준 약 33분치 경험입니다. 그걸 한 번의 갱신에 씁니다. 4000번 갱신하면:
4096 \times 24 \times 4000 \times \frac{1}{50\,\text{Hz}} \approx 7.9\times10^{6}\ \text{초} \approx \textbf{91일}
로봇 한 대로는 3개월 걸릴 경험을 두세 시간에 모읍니다. 이게 GPU 병렬 시뮬레이터를 쓰는 이유의 전부입니다.
병렬은 공짜가 아닙니다. 4096개 환경이 GPU 메모리에 전부 올라갑니다. 이 프로젝트 기준 RTX 4080 16GB에서 4096은 안정, 8192는 OOM 입니다. 그리고 5차시에서 보겠지만, 환경 수를 바꾸면 실험이 달라집니다 — 배치 크기가 바뀌니 학습 동역학도 바뀝니다. 비교 실험에서는 반드시 고정하세요.
3. Isaac Lab 스크립트의 3단 구조
Isaac Lab 스크립트를 처음 열면 이상한 것이 하나 보입니다. import 가 파일 맨 위에 없습니다.
# 1단계 — 앱을 먼저 띄운다
import argparse
from isaaclab.app import AppLauncher
parser = argparse.ArgumentParser()
AppLauncher.add_app_launcher_args(parser)
args_cli = parser.parse_args()
app_launcher = AppLauncher(args_cli)
simulation_app = app_launcher.app
# 2단계 — 이제서야 나머지를 import 한다
import isaaclab.sim as sim_utils
from isaaclab.sim import SimulationCfg, SimulationContext
# 3단계 — 씬을 만들고 돌린다
sim = SimulationContext(SimulationCfg(dt=1.0 / 200.0))
sim.reset()
while simulation_app.is_running():
sim.step()- 1
-
AppLauncher만 먼저 가져옵니다. 이건 앱 없이도 import 됩니다. - 2
- 여기서 Omniverse 앱이 실제로 뜹니다. 이 줄이 실행되면서 Isaac Sim 확장들이 등록됩니다.
- 3
-
이 아래부터
isaaclab.*을 쓸 수 있습니다. 위로 올리면ModuleNotFoundError입니다. - 4
-
dt = 1/200— 물리 스텝. 이 프로젝트의 값입니다. - 5
-
reset()이 물리 씬을 실제로 만듭니다. 이 전에는 어떤 asset 의data도 못 읽습니다.
① import 를 위로 올린다. 파이썬 습관대로 정리하면 바로 깨집니다. 린터가 “import 는 맨 위로” 라고 경고해도 무시해야 합니다. 이 프로젝트의 train.py·play.py 도 전부 같은 모양입니다.
② reset() 전에 data 를 읽는다. robot.data.joint_pos 같은 건 물리 씬이 만들어진 뒤에만 존재합니다. 그 전에는 None 이거나 예외입니다.
4. 시간이 두 개다 — 200 Hz 물리, 50 Hz 정책
이 프로젝트에는 시계가 두 개 돕니다. 헷갈리면 나중에 배포에서 반드시 사고가 납니다.
\underbrace{dt = \tfrac{1}{200}\,\text{s}}_{\text{물리 · PhysX}}, \qquad \underbrace{\texttt{decimation} = 4}_{\text{몇 번마다 정책을 부를까}}, \qquad \underbrace{\tfrac{200}{4} = 50\,\text{Hz}}_{\text{정책}}
즉 정책이 한 번 행동을 내면 물리는 4번 진행합니다. 그 4번 동안 관절은 PD 제어기가 같은 목표각을 향해 계속 밀고 있습니다.
왜 나눴나 — 신경망 추론은 비싸고, 물리는 촘촘해야 안정적입니다. 정책을 200 Hz 로 부르면 학습이 4배 느려지는데 얻는 게 거의 없습니다. 반대로 물리를 50 Hz 로 낮추면 접촉 계산이 부정확해져 로봇이 지면을 뚫습니다.
PD 제어기가 그 사이를 메웁니다. 정책은 “이 자세로 가라”는 목표만 20 ms 마다 주고, PD 는 5 ms 마다 그쪽으로 밀어 줍니다.
이 구조가 1차시의 관측(last_action 이 왜 관측에 들어가는지), 3차시의 히스토리 (5스텝 = 0.1초), 4차시의 롤아웃(24스텝 = 0.48초)에 전부 걸립니다.
5. 씬을 이루는 네 조각
Isaac Lab 환경은 결국 이 네 가지의 조합입니다. 1차시에서 볼 코드가 전부 여기서 나옵니다.
| 조각 | 클래스 | 무엇인가 | 1차시에서 |
|---|---|---|---|
| 로봇 | Articulation |
관절로 이어진 강체 묶음. 상태는 robot.data.* 로 읽는다 |
관측 45차원의 원천 |
| 구동기 | ActuatorCfg |
관절을 어떻게 움직일지 — PD 게인, 토크 한계 | 행동 → 토크 변환 |
| 지형 | TerrainImporterCfg |
평면 또는 생성된 타일 격자 | 지형 커리큘럼 |
| 센서 | ContactSensor / RayCaster |
접촉 여부 / 주변 지형 높이 | 종료 조건 · 특권 관측 |
flowchart LR ART["Articulation<br/>관절각 · 속도 · 자세"] --> OBS(["관측 45"]) ACT["ActuatorCfg<br/>PD 게인"] --> TAU["관절 토크"] TER["TerrainImporter<br/>지형 격자"] --> RAY CON["ContactSensor<br/>몸통 접촉"] --> DONE(["종료 조건"]) RAY["RayCaster<br/>height scan 187"] --> PRIV(["특권 관측"]) classDef a fill:#e3f2fd,stroke:#1976d2; classDef o fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px; classDef d fill:#ffebee,stroke:#c62828; class ART,ACT,TER,CON,RAY,TAU a; class OBS,PRIV o; class DONE d;
6. 미니 코딩 학습지
여기부터가 본론입니다. 다섯 장을 순서대로 채우면 1차시 환경의 모든 조각을 직접 만들어 본 셈이 됩니다.
실습 파일은 저장소의 exercises/stage1_isaac_basics/ 에 있습니다.
처음 한 번은 반드시 --viz kit 으로 보세요. 로봇이 실제로 어떻게 주저앉고 어떻게 버티는지 눈으로 봐야 숫자가 의미를 갖습니다. 기본은 headless 라 아무것도 안 뜹니다.
각 스크립트는 스스로 채점합니다. 끝까지 돌면 [PASS] 가 찍히고, 틀리면 그 자리에서 AssertionError 와 함께 무엇이 기대와 달랐는지 알려 줍니다. Isaac Sim 기동 때문에 한 번에 25초쯤 걸립니다.
학습지 1 — 빈 씬 띄우기 L0 · 읽기만
목표: §3의 3단 구조를 눈으로 확인한다. 쓸 코드는 없습니다.
확인할 것
import가simulation_app = app_launcher.app아래에 있다 → 위로 옮기면 어떻게 되는지 일부러 한 번 옮겨 보세요. 에러 메시지를 본 사람과 안 본 사람은 다릅니다.sim.reset()을 지우면 무슨 일이 일어나는가?GroundPlaneCfg를 지우면? (힌트: 로봇이 아직 없어도 상관없습니다)
통과 기준: [PASS] 2줄과 [OK].
학습지 2 — Go2 스폰하고 상태 읽기 L1 · 3~4줄
목표: 로봇을 올리고 상태를 읽는다. 1차시의 관측 45차원은 전부 여기서 읽는 값들의 조합입니다.
채울 곳: TODO(spawn-go2)
- 1
-
.copy()를 빼먹지 마세요.UNITREE_GO2_CFG는 모듈 전역 객체라, 직접 고치면 같은 프로세스의 다른 코드가 전부 영향을 받습니다. 디버깅하기 아주 어려운 종류의 버그입니다.
생각해 볼 것 — SPAWN_HEIGHT = 0.42 입니다. 원 논문은 0.34 m 인데 왜 더 높을까요? (답: 학습지 4의 생성 지형에 최대 0.1 m 짜리 boxes 가 있고, 리셋 때 xy 로 ±0.5 m 흔들리기 때문에 낮게 스폰하면 지형에 끼입니다.)
통과 기준: 관절 12개의 이름과 순서가 FL → FR → RL → RR 로 찍힌다.
학습지 3 — PD 제어와 액션 스케일 L1 · 한 줄
목표: 정책의 액션이 관절 목표로 어떻게 바뀌는지 이해한다. 1차시 §4의 핵심 수식입니다.
채울 곳: TODO(pd-target) — 딱 한 줄입니다.
- 1
- 정책은 “이 관절을 몇 rad 로” 가 아니라 “기본 자세에서 얼마나 벗어날지” 를 냅니다.
q_{des} = q_{default} + \text{scale} \times a, \qquad \text{scale} = 0.25
통과 기준
action = +1→ 관절 목표가 기본 자세에서 정확히0.25 rad벗어남action = 0→ 목표 = 기본 자세
생각해 볼 것 — 액션이 \pm 1 로 클립되면 관절 목표는 기본 자세 \pm 0.25 rad 안에 갇힙니다. 그런데 2차시에서 보겠지만 이 프로젝트는 clip_actions = 4.0 을 씁니다. 그러면 범위가 어떻게 되나요? 그리고 왜 그렇게 넓혔을까요?
이 학습지는 로봇 두 대를 나란히 세웁니다 — PD 게인이 다른 두 대입니다. --viz kit 으로 보면 같은 목표각을 줬는데 하나는 흐물거리고 하나는 뻣뻣합니다. K_p 가 무엇을 결정하는지 한 번에 이해됩니다.
학습지 4 — 지형 생성과 env_origins L1 · 5~8줄
목표: 무한 평면을 벗어난다. 3차시의 “지형 문맥 추정” 은 울퉁불퉁한 지형이 있어야 의미가 생깁니다.
채울 곳: TODO(terrain-cfg)
| 항목 | 값 | 왜 |
|---|---|---|
prim_path |
"/World/ground" |
height scanner 가 이 경로를 mesh 로 참조한다 |
terrain_type |
"generator" |
"plane" 은 단일 무한 평면 |
terrain_generator |
ROUGH_TERRAINS_CFG.copy() |
학습지 2와 같은 이유로 .copy() |
collision_group |
-1 |
전역 그룹 — 모든 env 가 이 지형과 충돌 |
num_rows / num_cols |
5 / 5 |
격자의 행(row)이 난이도입니다. 이것이 1차시에서 볼 지형 커리큘럼의 토대입니다 — 잘 걸으면 위 행으로 승급하고, 못 걸으면 내려갑니다. 5차시에서 terrain_levels 를 성능 지표로 읽는 것도 이 구조 때문입니다.
통과 기준: env_origins 가 격자 모양으로 배치되고, 각 env 가 서로 다른 타일 위에 선다.
학습지 5 — 센서: 접촉과 height scan L2 · 직접 구현
Stage 1 의 마지막이자 가장 중요한 학습지입니다. 여기서 읽는 두 값이 1차시의 종료 조건과 특권 관측의 원재료입니다.
ContactSensor → 몸통이 지면에 닿았는가 → 종료 조건
RayCaster → 로봇 주변 지형 높이 187개 → Oracle 의 특권 관측
채울 곳: TODO(height-scan) — 한 줄이지만 의미를 이해해야 맞습니다.
ray_hits_w[..., 2] 는 광선이 맞은 지점의 월드 좌표 z 입니다. 이걸 그대로 관측에 넣으면 로봇이 언덕 위에 있을 때와 계곡에 있을 때 같은 지형이 전혀 다른 숫자가 됩니다.
관측이 알려 주어야 하는 것은 “내 몸통이 발밑 지형보다 얼마나 높은가” 입니다.
base_z는(num_envs,),hit_z는(num_envs, num_rays)— 브로드캐스트를 맞추세요.BASE_HEIGHT_TARGET = 0.30을 빼므로 목표 높이로 서 있으면 결과가 0 입니다.- 이게 프로젝트의
mdp.height_scan이 하는 일 그대로입니다.
1차시 §5.4에서 이 센서가 보상을 통해 학습을 통째로 죽인 사건을 다룹니다. 힌트를 미리 드리면 — 광선이 아무것도 못 맞히면 ray_hits_w 에 뭐가 들어갈까요? 그 값이 그대로 보상에 들어가면요?
통과 기준: 평지에 목표 높이로 서 있을 때 height_scan 이 0 근처, 몸통을 들면 양수.
7. 자주 막히는 곳
| 증상 | 원인 | 해결 |
|---|---|---|
ModuleNotFoundError: isaaclab |
import 를 파일 위로 올림 |
app_launcher.app 아래로 (§3) |
data 가 None / 예외 |
sim.reset() 전에 읽음 |
reset() 이후에 접근 |
| 첫 실행이 몇 분씩 걸림 | 셰이더 캐싱 | 정상입니다. 두 번째부터 빨라집니다 |
| 창이 안 뜸 | 기본이 headless | --viz kit 추가 |
| 로봇이 지면을 뚫고 떨어짐 | 바닥이 없거나 스폰 높이가 낮음 | GroundPlaneCfg 확인, SPAWN_HEIGHT 올리기 |
--headless 경고 |
현재 Isaac Lab 에서 deprecated | 아무것도 안 주면 이미 headless |
| 4096 env 에서 OOM / Bus Error | VRAM·스왑 부족 | --num_envs 를 낮추거나 스왑 8GB 이상 |
8. 핵심 정리
- 세 층: PhysX(물리) → Isaac Sim(씬) → Isaac Lab(RL 환경) → rsl_rl(학습기). 이 시리즈의 1차시는 Isaac Lab, 2차시는 rsl_rl, 4차시는 그 둘의 접합부를 뜯는다.
- 4096 병렬은 사치가 아니라 필수다 — 로봇 한 대로 91일 걸릴 경험을 두세 시간에 모은다. 대신 GPU 메모리가 한계이고, 환경 수를 바꾸면 실험이 달라진다.
- Isaac Lab 스크립트는 3단 구조다: 앱 기동 → import → 씬.
import순서는 바꿀 수 없고,reset()전에는data를 못 읽는다. - 시계가 둘이다 — 물리 200 Hz, 정책 50 Hz(
decimation=4). 그 사이를 PD 가 메운다. - 환경은 네 조각의 조합이다:
Articulation·ActuatorCfg·TerrainImporter· 센서. 1차시의 관측·보상·종료가 전부 여기서 나온다. - 학습지 5장을 직접 채우세요. 특히 5번(height scan)은 1차시에서 그대로 다시 씁니다.
이제 진짜 환경을 만듭니다. Go2의 12 자유도, actor 가 보는 45차원이 정확히 무엇으로 채워져 있는지, 그리고 거기에 없는 것(선속도·지형)이 왜 이 시리즈 전체의 문제 설정이 되는지를 코드로 봅니다.