Isaac Sim & Isaac Lab 시작하기 — 시뮬레이터를 손으로 만져보기

보지 않고 걷기 — CENet + 비대칭 PPO · 0차시

Author

JungYeon Lee

Published

August 31, 2026

강의 로드맵

회차 주제
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장으로 끝냅니다. 읽기만 하면 안 붙습니다.

Tip오늘의 목표
  1. Isaac Sim · Isaac Lab · rsl_rl 세 층이 각각 무엇을 하는지 구분한다.
  2. Isaac Lab 스크립트의 3단 구조와 시간 축(200 Hz 물리 / 50 Hz 정책)을 이해한다.
  3. 학습지 5장으로 씬·로봇·PD·지형·센서를 직접 만들어 본다.

설치는 다루지 않습니다. Isaac Sim 6.0 + Isaac Lab 이 이미 돌아간다고 가정합니다 (설치는 저장소의 setup.qmd 참고). 여기서는 설치된 다음에 무엇을 알아야 하는지만 봅니다.


1. 세 개의 층 — 누가 무엇을 하나

가장 먼저 헷갈리는 지점입니다. 이름이 비슷한 것이 셋 있고, 역할이 전부 다릅니다.

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;
Figure 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 병렬 시뮬레이터를 쓰는 이유의 전부입니다.

Important

병렬은 공짜가 아닙니다. 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 도 못 읽습니다.
Important처음 하루를 날리는 실수 두 개

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;
Figure 2: 네 조각이 관측·보상·종료로 번역되는 경로

6. 미니 코딩 학습지

여기부터가 본론입니다. 다섯 장을 순서대로 채우면 1차시 환경의 모든 조각을 직접 만들어 본 셈이 됩니다.

실습 파일은 저장소의 exercises/stage1_isaac_basics/ 에 있습니다.

cd exercises/stage1_isaac_basics/task01_empty_scene
python solution.py             # 정답 실행 — 무엇이 나와야 하는지 먼저 본다
python starter.py              # 내 코드 실행
python solution.py --viz kit   # GUI 창으로 보기
Important

처음 한 번은 반드시 --viz kit 으로 보세요. 로봇이 실제로 어떻게 주저앉고 어떻게 버티는지 눈으로 봐야 숫자가 의미를 갖습니다. 기본은 headless 라 아무것도 안 뜹니다.

각 스크립트는 스스로 채점합니다. 끝까지 돌면 [PASS] 가 찍히고, 틀리면 그 자리에서 AssertionError 와 함께 무엇이 기대와 달랐는지 알려 줍니다. Isaac Sim 기동 때문에 한 번에 25초쯤 걸립니다.

학습지 1 — 빈 씬 띄우기 L0 · 읽기만

목표: §3의 3단 구조를 눈으로 확인한다. 쓸 코드는 없습니다.

확인할 것

  • importsimulation_app = app_launcher.app 아래에 있다 → 위로 옮기면 어떻게 되는지 일부러 한 번 옮겨 보세요. 에러 메시지를 본 사람과 안 본 사람은 다릅니다.
  • sim.reset() 을 지우면 무슨 일이 일어나는가?
  • GroundPlaneCfg 를 지우면? (힌트: 로봇이 아직 없어도 상관없습니다)

통과 기준: [PASS] 2줄과 [OK].

학습지 2 — Go2 스폰하고 상태 읽기 L1 · 3~4줄

목표: 로봇을 올리고 상태를 읽는다. 1차시의 관측 45차원은 전부 여기서 읽는 값들의 조합입니다.

채울 곳: TODO(spawn-go2)

# UNITREE_GO2_CFG 를 복사해서 위치를 지정하고 Articulation 을 만든다
robot_cfg = UNITREE_GO2_CFG.copy()
robot_cfg.prim_path = "/World/Robot"
robot_cfg.init_state.pos = (0.0, 0.0, SPAWN_HEIGHT)
robot = Articulation(robot_cfg)
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) — 딱 한 줄입니다.

def action_to_target(robot, action):
    """정책 액션(±1)을 관절 위치 목표(rad)로 바꾼다."""
    return robot.data.default_joint_pos + action * ACTION_SCALE
1
정책은 “이 관절을 몇 rad 로” 가 아니라 “기본 자세에서 얼마나 벗어날지” 를 냅니다.

q_{des} = q_{default} + \text{scale} \times a, \qquad \text{scale} = 0.25

통과 기준

  1. action = +1 → 관절 목표가 기본 자세에서 정확히 0.25 rad 벗어남
  2. 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
Important

격자의 행(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 입니다. 이걸 그대로 관측에 넣으면 로봇이 언덕 위에 있을 때와 계곡에 있을 때 같은 지형이 전혀 다른 숫자가 됩니다.

관측이 알려 주어야 하는 것은 “내 몸통이 발밑 지형보다 얼마나 높은가” 입니다.

height_scan = base_z - hit_z - BASE_HEIGHT_TARGET
#             (N,)     (N, R)    스칼라 0.30
  • base_z(num_envs,), hit_z(num_envs, num_rays)브로드캐스트를 맞추세요.
  • BASE_HEIGHT_TARGET = 0.30 을 빼므로 목표 높이로 서 있으면 결과가 0 입니다.
  • 이게 프로젝트의 mdp.height_scan 이 하는 일 그대로입니다.
Important

1차시 §5.4에서 이 센서가 보상을 통해 학습을 통째로 죽인 사건을 다룹니다. 힌트를 미리 드리면 — 광선이 아무것도 못 맞히면 ray_hits_w 에 뭐가 들어갈까요? 그 값이 그대로 보상에 들어가면요?

통과 기준: 평지에 목표 높이로 서 있을 때 height_scan 이 0 근처, 몸통을 들면 양수.


7. 자주 막히는 곳

증상 원인 해결
ModuleNotFoundError: isaaclab import 를 파일 위로 올림 app_launcher.app 아래로 (§3)
dataNone / 예외 sim.reset() 전에 읽음 reset() 이후에 접근
첫 실행이 몇 분씩 걸림 셰이더 캐싱 정상입니다. 두 번째부터 빨라집니다
창이 안 뜸 기본이 headless --viz kit 추가
로봇이 지면을 뚫고 떨어짐 바닥이 없거나 스폰 높이가 낮음 GroundPlaneCfg 확인, SPAWN_HEIGHT 올리기
--headless 경고 현재 Isaac Lab 에서 deprecated 아무것도 안 주면 이미 headless
4096 env 에서 OOM / Bus Error VRAM·스왑 부족 --num_envs 를 낮추거나 스왑 8GB 이상

채점 스크립트는 Isaac Sim 번들 파이썬으로 돌려야 합니다 — 시스템 python3 에는 torch 가 없습니다. 1차시부터의 실습(stage2 이후)은 대부분 Isaac Sim 없이 1초 만에 도는 check.py 가 붙어 있어 훨씬 빠릅니다.


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차시에서 그대로 다시 씁니다.
Note다음 차시 예고 — 1차시: 관측을 무엇으로 채울 것인가

이제 진짜 환경을 만듭니다. Go2의 12 자유도, actor 가 보는 45차원이 정확히 무엇으로 채워져 있는지, 그리고 거기에 없는 것(선속도·지형)이 왜 이 시리즈 전체의 문제 설정이 되는지를 코드로 봅니다.