Curieux.JY
  • JungYeon Lee
  • Post
  • 🕸️ Graph
  • Lecture
  • Note

On this page

  • 이 글의 범위
  • 1. 물리 속성: 그냥 된다
  • 2. 기하: set_scales()는 아무것도 하지 않는다
  • 3. 왜 “DR이 안 걸린 것”보다 나쁜가
  • 4. 기하를 진짜로 env마다 다르게: MultiAssetSpawnerCfg
  • 5. 함정 모음
    • 함정 1 — 그룹이 여러 개면 조합이 데카르트 곱
    • 함정 2 — 버전마다 API가 다르다
    • 함정 3 — clone plan은 씬 cfg에 선언된 에셋만 본다
    • 함정 4 — 복제가 결과를 덮어쓴다 (가장 오래 헤맨 것)
    • 함정 5 — 비율 가중치는 블록 크기를 반영해야 한다
    • 함정 6 — 변종 목록의 순서, 그리고 평가 env 수
  • 6. 검증을 코드에 남기기
  • 체크리스트
  • 마지막으로

📝 Isaac Lab에서 env마다 다르게 주기: 되는 것, 안 되는 것, 조용히 실패하는 것

isaacsim
isaaclab
physx
usd
domain-randomization
rl
2026
도메인 랜덤화에서 질량이나 마찰은 view API로 env마다 쓰면 그대로 먹는다. 그런데 기하(크기·형태)는 같은 방식으로 쓰면 되읽기까지 성공하고서 렌더링에도 물리에도 반영되지 않는다. 무엇이 되고 무엇이 안 되는지, 기하를 진짜로 env마다 다르게 만드는 방법, 그리고 이런 종류의 실패를 자동으로 잡는 방법.
Published

August 18, 2026

이 글의 범위

Isaac Lab에서 env마다 다른 값을 주려고 할 때 겪는 문제를 정리한다. 강화학습 도메인 랜덤화가 대표적이지만, 병렬 env를 쓰면서 각 env를 조금씩 다르게 만들고 싶은 모든 경우에 해당한다.

핵심은 이거다. 속성 종류에 따라 방법이 완전히 다르고, 잘못된 방법은 에러 없이 조용히 무시된다.

속성 방법 조용히 실패하나
질량, 마찰, 관성, COM 물리 뷰 API (set_masses_index 등) 아니오 (바로 반영)
초기 위치·자세·속도 write_root_state_to_sim 아니오
PD 게인, 관절 한계 actuator / articulation API 아니오
기하 (크기·형태·비율) 프림 변종 스폰 예 — 아래 전부

기하만 다르다. 런타임에 바꿀 수 있는 값이 아니라 스폰할 때 프림에 박히는 값 이기 때문이다.

1. 물리 속성: 그냥 된다

질량·마찰·COM은 env별로 텐서를 만들어 뷰에 쓰면 끝이다.

# 질량: env마다 다른 값
masses = torch.empty(len(env_ids), device=device).uniform_(0.05, 0.15)
asset.set_masses_index(masses=masses.unsqueeze(-1), env_ids=env_ids)

# 마찰: material properties는 (num_envs, num_shapes, 3) = [static, dynamic, restitution]
view = asset.root_physx_view
props = view.get_material_properties()
props[env_ids, :, 0] = static_friction.unsqueeze(-1)
props[env_ids, :, 1] = dynamic_friction.unsqueeze(-1)
view.set_material_properties(props, indices)
NoteCOM은 7-element pose를 받는다

set_coms_index()에 위치 3개만 넘기면 조용히 예외가 난다(잡아서 무시하는 코드였다면 영영 모른다). 위치 3 + 쿼터니언 4다.

com_pose = torch.zeros(num_envs, 1, 7, device=device)
com_pose[:, 0, 0:3] = com_offsets
com_pose[:, 0, 6] = 1.0          # identity quaternion, xyzw
asset.set_coms_index(coms=com_pose, env_ids=env_ids)

되는지 확인하려면 쓴 값을 그대로 읽지 말고 뷰에서 되읽는다.

print(view.get_masses()[:6].flatten())
# [0.0827, 0.1065, 0.073, 0.0778, 0.1, 0.1]

2. 기하: set_scales()는 아무것도 하지 않는다

여기서부터 문제다. 크기를 env마다 다르게 주려고 이렇게 쓰기 쉽다.

view = XformPrimView("/World/envs/env_.*/Object", device=device)
view.set_scales(scale_factors)          # (num_envs, 3)

에러도 없고, 되읽으면 쓴 값이 나온다.

print(view.get_scales()[:2])   # [[0.5 0.5 0.5], [0.5 0.5 0.5]]   성공처럼 보인다

그런데 스테이지를 직접 보면 반영이 안 되어 있다.

from pxr import Usd, UsdGeom

prim = stage.GetPrimAtPath("/World/envs/env_0/Object")
ops = {op.GetOpName(): op.Get() for op in UsdGeom.Xformable(prim).GetOrderedXformOps()}
print(ops["xformOp:scale"])            # (1, 1, 1)

cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(),
                          [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
print(cache.ComputeWorldBound(prim).ComputeAlignedRange().GetSize())
# 0.09 x 0.09 x 0.09   -- 0.5배를 요청했는데 원래 크기

set_scales()는 fabric 버퍼에만 쓴다. USD 스테이지에도, PhysX 충돌 형상에도 전파되지 않는다. 렌더링도 물리도 그대로다.

Important원칙 1 — 되읽기는 검증이 아니다

같은 버퍼를 왕복했을 뿐일 수 있다. 소비하는 쪽에서 확인해야 한다. 기하는 바운딩 박스, 질량은 물리 뷰, 접촉은 실제 접촉 힘.

같은 API를 쓰는 다른 축도 전부 같이 의심해야 한다. 크기뿐 아니라 “높이만 늘리기” 같은 비율 DR도 set_scales()로 Z만 곱하는 식이면 똑같이 무효다.

 env  scale   원기둥 bbox (mm)
   0   0.50   35.0  35.0  30.0     <- 30 = height(60) * 0.50
   1   0.55   38.5  38.5  33.0     <- 33 = height(60) * 0.55

z가 정확히 height * scale이면 비율 DR은 걸리지 않은 것이다.

3. 왜 “DR이 안 걸린 것”보다 나쁜가

조용한 실패의 진짜 비용은 DR이 없어지는 게 아니라 다른 데이터와 어긋나는 것 이다. 세 가지가 전형적이다.

라벨과 실물이 다르다. 크기를 관측/특권 정보에 넣고 있었다면, 그 채널은 실제 물체와 무관한 값을 학습한다. 노이즈를 특권 정보로 주는 셈이다.

전처리 산출물과 어긋난다. 예를 들어 파지 자세 캐시처럼 오프라인에서 만든 데이터가 있다면, 그쪽은 스폰 치수를 제대로 반영해 만들어졌을 수 있다. 그러면 “22 mm 물체용 손 자세로 45 mm 물체를 잡으려는” 상태가 되고, 리셋하자마자 실패한다. 그 실패를 물체 특성 탓으로 해석하면 몇 달을 잘못된 방향으로 쓴다.

DR이 라벨을 덮어쓴다. 흔한 패턴:

self.obj_masses[env_ids]  = priv["mass"]
self.obj_frictions[env_ids] = priv["friction"]
self.obj_scales[env_ids]  = priv["scale"]   # <- 새로 뽑은 난수

질량·마찰은 실제로 샘플링해서 물리에 쓰므로 샘플값을 보고하는 게 맞다. 그런데 크기는 샘플링 대상이 아니다. 어느 프림이 스폰됐는지로 이미 정해져 있다. 여기에 난수를 덮어쓰면 라벨과 실물이 영구히 어긋난다.

Important원칙 2 — 값의 출처는 하나여야 한다

“스폰이 정하는 값”과 “매 스텝 샘플링하는 값”을 같은 버퍼에 쓰지 말 것.

4. 기하를 진짜로 env마다 다르게: MultiAssetSpawnerCfg

정식 방법은 변종을 미리 만들어 두고 env마다 그중 하나를 프림으로 갖게 하는 것이다.

from isaaclab.sim.spawners.wrappers.wrappers_cfg import MultiAssetSpawnerCfg

variants = [base_spawn.replace(radius=r) for r in (0.02, 0.03, 0.04)]
cfg = asset_cfg.replace(
    prim_path="{ENV_REGEX_NS}/Object",
    spawn=MultiAssetSpawnerCfg(assets_cfg=variants),
)

동작 순서:

  1. InteractiveScene._build_clone_plan_from_cfg()가 spawner의 변종 개수를 센다
  2. cloner_utils.make_clone_plan(...)이 clone_mask(shape [변종수, env수])를 만든다 — 어느 env가 어느 변종을 쓰는지
  3. 그 결과로 MultiAssetSpawnerCfg.spawn_paths가 채워진다
  4. spawn_multi_asset이 proto 프림을 만들고 각 env에 하나씩 복사한 뒤 proto를 지운다
Tip메모리는 늘지 않는다

변종을 N개 선언해도 env당 프림은 1개다. proto는 복사 후 삭제된다. “N배 무거워진다”는 직관은 틀렸다.

배정 규칙은 CloneCfg.clone_strategy다.

# isaaclab/cloner/cloner_strategies.py
def random(combinations, num_clones, device): ...      # 기본값
def sequential(combinations, num_clones, device):      # 결정적 라운드로빈
    return combinations[torch.arange(num_clones, device=device) % len(combinations)]

기본값이 random이므로 재현성이 필요하면 sequential로 바꿔야 한다.

그리고 env들이 서로 복사본이 아니게 되므로:

scene_cfg.replicate_physics = False

스포너도 /isaaclab/spawn/multi_assets carb 설정을 켜서 PhysX 파서가 스테이지를 최적화로 뭉개지 않게 알린다.

5. 함정 모음

함정 1 — 그룹이 여러 개면 조합이 데카르트 곱

make_clone_plan은 그룹(에셋 종류)마다 하나씩 골라 모든 조합을 만든다.

all_combos = list(itertools.product(*[range(s) for s in group_sizes]))

3종 × 9변종이면 조합이 729개고, 한 env의 세 에셋이 각각 다른 변종을 받는다. env_id % N 같은 단순 규칙으로 역산하면 틀린다. clone_mask를 읽거나, 아래처럼 배정을 직접 하는 편이 안전하다.

함정 2 — 버전마다 API가 다르다

같은 MultiAssetSpawnerCfg인데 브랜치마다 동작이 다르다.

브랜치 동작
main random_choice로 env마다 proto를 하나씩 복사. 그 자체로 완결
develop, release/3.0.0-beta* random_choice 제거. 씬이 spawn_paths를 채워줘야 동작

후자의 docstring은 InteractiveSceneCfg.random_heterogeneous_cloning을 쓰라고 안내하는데 그런 필드는 없다. deprecation 문구에만 남아 있다. 실제 대체물은 위의 clone plan이다. 이 문구만 보고 “지원 안 함”으로 결론내면 틀린다.

# 설치본이 어느 쪽인지
grep -n "random_choice" $ISAACLAB/source/isaaclab/isaaclab/sim/spawners/wrappers/wrappers.py
grep -rn "heterogeneous" $ISAACLAB/source/isaaclab/isaaclab/scene/

함정 3 — clone plan은 씬 cfg에 선언된 에셋만 본다

def _is_scene_setup_from_cfg(self) -> bool:
    return any(
        not (name in InteractiveSceneCfg.__dataclass_fields__ or cfg is None)
        for name, cfg in self.cfg.__dict__.items()
    )

DirectRLEnv._setup_scene() 안에서 RigidObject(cfg)를 직접 만드는 방식(Direct 워크플로에서 흔하다)이면 이 조건에 걸리지 않는다. clone plan이 없으니 spawn_paths가 None이고, 스포너가 예외를 던진다.

self.cfg.__dict__를 순회하므로 동적 속성으로 붙여도 인식된다.

setattr(cfg.scene, "my_object", object_cfg)   # 새 cfg 클래스 없이도 잡힌다

함정 4 — 복제가 결과를 덮어쓴다 (가장 오래 헤맨 것)

per-env 스폰을 직접 구현했는데도 모든 env가 변종 0을 받는다면, 스포너 문제가 아니라 순서 문제일 가능성이 크다.

self.robot  = Articulation(robot_cfg)
self.object = RigidObject(object_cfg)                   # env마다 다르게 스폰
self.scene.clone_environments(copy_from_source=False)   # <- env_0을 전체에 복제

clone_environments()가 env_0의 내용을 나머지에 복제하면서 애써 만든 변종을 전부 덮어쓴다. env마다 달라야 하는 에셋은 복제 이후에 만들어야 한다.

self.robot = Articulation(robot_cfg)     # 모든 env 동일 -> 복제 대상
apply_contact_reporting()                # 복제 전에 해야 사본이 물려받는다
self.scene.clone_environments(copy_from_source=False)

self.object = RigidObject(object_cfg)    # 복제 후: env마다 다른 프림

빈 env 프림(/World/envs/env_0..N)은 에셋 스폰 전에 이미 만들어져 있으므로, 복제 후에 스폰해도 find_matching_prim_paths("/World/envs/env_.*")가 전부 찾는다.

함정 5 — 비율 가중치는 블록 크기를 반영해야 한다

종류별 비율(예: [0.2, 0.3, 0.5])을 변종 목록으로 재현할 때, “종류 블록을 비율만큼 반복”하면 블록 크기가 다르면 틀린다.

한 종류의 블록 크기는 그 종류가 갖는 하위 변종 수(크기 × 비율 × …)다. 어떤 종류는 하위 축이 적을 수 있다. 그러면 같은 반복 횟수라도 env 점유율이 달라진다.

반복 횟수는 원하는비율 / 블록크기에 비례해야 한다.

ratios = [w / len(block[kind]) for w, kind in zip(want, kinds)]
lo = min(r for r in ratios if r > 0)
best = None
for k in range(1, 13):                       # 배수를 훑어 오차 최소인 정수 반복수
    reps = [max(1, round(k * r / lo)) for r in ratios]
    total = sum(rep * len(block[kind]) for rep, kind in zip(reps, kinds))
    if total > 400:                          # 변종 폭발 방지
        break
    got = [rep * len(block[k2]) / total for rep, k2 in zip(reps, kinds)]
    err = sum(abs(g - w) for g, w in zip(got, want))
    ...

실측 예: 블록 크기를 무시하면 [0.2, 0.3, 0.5]가 10 / 30 / 59%로 나왔고, 위 방식으로 고치니 정확히 20 / 30 / 50%가 됐다.

함정 6 — 변종 목록의 순서, 그리고 평가 env 수

env가 i % len(variants)로 변종을 받는다면, 목록이 종류별 블록으로 정렬되어 있을 때 env 수가 변종 수보다 적으면 앞쪽 블록만 쓰인다.

20 env, 90 변종, 종류순 정렬
-> A 9개, B 11개, C 0개        # C가 아예 없다

고정 시드로 섞으면 어떤 접두부도 대표성 있는 표본이 된다.

import random
random.Random(0).shuffle(variants)      # 재현 가능하면서 섞인다

여기서 더 중요한 결론이 따라온다. 평가 env 수는 변종 수 이상이어야 한다. 변종이 270개인데 16 env로 평가하면 공간의 6%만 보는 것이고, 그건 정책이 아니라 그 16개가 뽑은 제비를 재는 것이다.

  • 영상 녹화: 적은 env로 충분하다 (보통 env 0 하나만 찍는다)
  • 수치 측정: 각 변종이 3~4번은 나오도록 — 270 변종이면 1024 env

env 수가 변종 수보다 적으면 경고를 찍게 해두는 편이 좋다.

6. 검증을 코드에 남기기

이런 버그의 본질은 “안다고 믿었는데 확인한 적이 없다”는 것이다. 그래서 시작할 때 스테이지에서 실측해 어긋나면 죽게 만드는 게 가장 확실하다.

def verify_per_env_geometry(stage, prim_path_tpl, expected_size_fn, num_envs, stride=1):
    """env를 표본추출해 실제 bbox가 그 env의 변종이 함의하는 크기와 같은지 확인."""
    cache = UsdGeom.BBoxCache(Usd.TimeCode.Default(),
                              [UsdGeom.Tokens.default_, UsdGeom.Tokens.render])
    for e in range(0, num_envs, stride):
        prim = stage.GetPrimAtPath(prim_path_tpl.format(e))
        if not prim:
            continue
        size = cache.ComputeWorldBound(prim).ComputeAlignedRange().GetSize()
        ex, ez = expected_size_fn(e)          # 그 env의 변종에서 계산한 기대값
        if abs(size[0] - ex) > 1e-4 or abs(size[2] - ez) > 1e-4:
            raise RuntimeError(
                f"env {e}: 실측 {1000*size[0]:.1f} x {1000*size[2]:.1f} mm, "
                f"변종이 함의하는 값 {1000*ex:.1f} x {1000*ez:.1f} mm"
            )

물리 속성도 같은 방식으로 뷰에서 되읽어 확인한다. 멀티에셋 환경이라면 비활성 프림에 기본값이 남아 있는 건 정상이므로, 그 env가 실제로 쓰는 에셋만 비교해야 한다.

체크리스트

마지막으로

몇 달 동안 이 위에서 실험을 돌렸는데 아무도 눈치채지 못한 이유가 있었다.

  • 로그가 성공처럼 보였다. Applied per-env scaling: factors 0.5 ~ 0.9는 요청값을 찍은 것인데 결과처럼 읽힌다
  • 되읽기가 통과했다. 같은 fabric 버퍼를 왕복하니 당연하다
  • 결과가 그럴듯했다. 작은 설정에서 성능이 나빴고 “작은 게 어렵다”는 해석이 자연스러웠다. 실제로는 전처리 데이터와 어긋나서 시작하자마자 실패한 것이었다
  • 코드 주석에는 경고가 있었는데 설정 주석은 반대로 말했다

결국 발견은 코드가 아니라 렌더링을 눈으로 본 사람이 했다. “이 둘이 같은 크기로 보이는데?” 한마디였다.

자동 검증이 없는 값은 언젠가 조용히 틀린다. 그리고 조용히 틀린 값 위에 쌓은 실험은 전부 다시 해야 한다.

Copyright 2026, JungYeon Lee