Phase 2-2까지 하고 로드맵을 다시 봤더니, Phase 2 카드에 "센서(RGB-D) 시뮬레이션 · ROS2 ↔ 시뮬레이터 브릿지"가 아직 안 끝난 채로 남아있었다. 굳이 되돌아가지 않고, 어차피 Phase 3(인지)를 시작하려면 제일 먼저 필요한 게 카메라라서 이번 세션 첫 단계로 자연스럽게 흡수시켰다. 오늘 한 세션에서 카메라 브릿지(3-1)랑 YOLO 인식(3-2)까지 이어서 했다.

① 손목에 카메라 달기 (eye-in-hand)

카메라를 세상에 고정시키는 대신, 팔 끝(hand_link)에 고정 조인트로 바로 붙였다. 실제 로봇팔이 물건을 집을 때 흔히 쓰는 방식이라고 한다 — 팔이 어디로 움직이든 "손이 보는 시야"를 카메라가 그대로 보게 된다.

Gazebo의 sensor type="depth" + libgazebo_ros_camera.so 플러그인 조합을 썼다. 예전 Gazebo9의 Kinect 스타일 플러그인(libgazebo_ros_openni_kinect.so) 역할을 ROS2 포트에서는 이 플러그인이 흡수했다고 한다. 컬러/깊이/포인트클라우드가 한 번에 나온다.

/hand_camera/image_raw          컬러 이미지
/hand_camera/depth/image_raw    깊이 이미지
/hand_camera/depth/points       포인트클라우드

camera_bridge_check라는 노드를 만들어서, 이 이미지가 진짜 계속 들어오는지(shape/평균 밝기)를 확인했다. 검정 화면도 랜덤 노이즈도 아니고 안정적인 값이 계속 찍혀서, 카메라 → ROS2 브릿지가 살아있다는 걸 확인했다.

② RViz2엔 왜 방/테이블이 안 보이나

처음엔 팔의 기본 자세(관절 0°, 0°)에서 카메라가 가까운 벽만 보고 있어서 화면이 회색 두 칸(위: 벽, 아래: 바닥)으로만 나왔다. 그 김에 헷갈렸던 걸 하나 정리했다 — RViz2 메인 3D 뷰에는 방 벽/테이블이 아예 안 보인다. room.world에 넣은 벽/테이블은 Gazebo 월드(SDF)에 직접 넣은 static 모델이라 /robot_description(URDF)에는 없고, RViz2는 ROS2로 발행되는 것만 그리지 .world 파일 자체는 읽지 않기 때문이다. 반대로 카메라 이미지엔 벽/테이블이 나오는데, 이건 그 이미지가 RViz가 그리는 게 아니라 Gazebo 렌더러가 실제 월드를 찍어서 토픽으로 보내주기 때문이다.

③ 팔 각도 계산해서 테이블 조준하기

room.world의 테이블이 정확히 (x=1.2, y=1.2)에 있어서, 원점(팔 회전축)에서 보면 딱 45° 대각선 방향이다. 두 관절(어깨/팔꿈치)이 전부 Z축 회전이라 팔꿈치를 0°로 펴두면, 어깨만 45°로 돌렸을 때 곧게 뻗은 팔+카메라가 정확히 그 대각선 위에 놓여서 테이블을 정면으로 보게 된다.

ros2 topic pub --once /joint_trajectory_controller/joint_trajectory \
  trajectory_msgs/msg/JointTrajectory \
  "{joint_names: ['shoulder_joint','elbow_joint'], points: [{positions: [0.785398, 0.0], time_from_start: {sec: 3}}]}"

계산대로 화면 정중앙에 테이블이 잡혔다.

 

(참고로 두 관절 다 Z축 회전이라, 카메라는 항상 바닥에서 약 0.1m 높이에서 순수 수평으로만 본다. 테이블이 마침 바닥~0.4m 높이라 그 안에 걸쳐 보인 거고, 카메라를 위아래로 기울이려면 팔에 다른 축의 회전 관절이 있어야 한다.)

④ YOLO 인식 노드 붙이기

여기서부터 3-2. camera_bridge_check의 콜백(이미지 받아서 shape만 로그로 찍던 부분)을 YOLOv8n(ultralytics, COCO 80종 사전학습) 추론으로 바꿨다. 결과는 두 가지로 발행한다.

  • /hand_camera/detections: JSON 문자열로 [{"class": "cup", "confidence": 0.83, "bbox": [...]}] 형태
  • /hand_camera/detections_image: 바운딩박스 그려진 디버그 이미지 (RViz2에 새 디스플레이로 추가)

표준 메시지 타입인 vision_msgs 대신 std_msgs/String(JSON)을 쓴 건, vision_msgs의 필드 구성이 버전(3.x/4.x)마다 꽤 달라서 지금 설치된 버전을 확인 없이 맞추면 빌드가 깨질 위험이 있었기 때문이다. 우선 확실히 동작하는 방식으로 가고, 나중에(Phase 4에서 좌표를 더 정교하게 다뤄야 할 때) 정식 타입으로 바꾸는 걸 고려하기로 했다.

⑤ numpy 버전 충돌 두 번 겪기

pip install ultralytics를 하고 나니 cv_bridge가 AttributeError: _ARRAY_API not found로 깨졌다. ultralytics가 numpy를 2.x로 업그레이드시켰는데, ROS2 Humble의 cv_bridge는 numpy 1.x C API에 맞춰 컴파일된 바이너리라서 생긴 문제였다.

pip install "numpy<2"

numpy를 1.26.4로 내려서 해결했는데, 이번엔 반대 방향 충돌이 났다. ultralytics가 같이 설치한 pip판 opencv-python이 numpy>=2를 요구한다는 경고가 뜬 것. 시스템에 이미 ROS(apt)용 python3-opencv가 cv_bridge와 궁합 맞게 깔려있어서, pip판을 지우고 시스템 버전을 쓰도록 정리했다.

pip uninstall -y opencv-python opencv-python-headless

교훈: 같은 파이썬 환경에 ROS(apt)가 설치한 라이브러리와 pip이 설치한 라이브러리가 섞이면 numpy 버전 기대치가 어긋나기 쉽다. "먼저 있었고 잘 동작하던 쪽"을 기준으로 잡고, 나중에 들어온 pip 패키지 쪽을 정리하는 방향이 안전했다.

⑥ Gazebo 상자는 왜 안 잡히나 — 모델 자체를 따로 검증

문제를 다 해결하고 실행했는데, room.world의 테이블(밋밋한 갈색 박스)엔 계속 "탐지된 물체 없음"만 떴다. 이게 아직 남은 설치 문제인지, 아니면 "상자가 실제 사진처럼 안 생겨서"인지 구분하려고, ROS2/Gazebo와 완전히 분리된 검증 스크립트(yolo_sanity_check.py, colcon build 불필요)를 만들어서 ultralytics 표준 샘플 사진(버스+사람들)에 같은 모델을 돌려봤다.

버스(0.87), 사람 4명(0.87/0.85/0.83/0.26), 정지 표지판(0.26)까지 정확히 잡아냈다 — 잘 보이는 건 확신 높게, 가려지거나 애매한 건 확신 낮게 잡는 전형적인 YOLO 패턴 그대로였다. 즉 모델/설치는 완전히 정상이고, Gazebo 상자가 안 잡히는 건 버그가 아니라 실제 사진으로 학습된 모델 입장에서 그 상자가 COCO의 어떤 물체와도 안 닮아서 생기는, 예상됐던 결과(sim-to-real 격차)였다.

덤으로 이 실행에서 CUDA initialization: driver too old 경고를 봤다. GPU 드라이버가 오래돼서 가속이 안 되고 CPU로 도는 중이고, 프레임당 추론에 10초 가까이 걸렸다. 지금 단계엔 문제없지만 나중에 실시간 반응이 필요해지면 짚어야 할 부분으로 남겨뒀다.

다음 편 (Phase 4) 예고

인지(카메라 + YOLO) 기본기는 여기까지. 다음은 MoveIt2로 실제 조작(그리핑) 단계로 넘어간다.

Phase 2-1에서 2관절 평면팔을 Gazebo 물리 시뮬레이터 안에서 실제로 움직이는 데까지 성공했다. Phase 3(인지)로 바로 넘어가지 않고 Phase 2를 조금 더 파보기로 했다 — 오늘(2-2)은 세 가지를 정하고 시작했는데, 하다 보니 두 가지가 더 붙었다.

① RViz2 + Gazebo, 같이 띄워서 눈으로 확인

Phase 1~2-1을 거치면서 "RViz2는 그림판, Gazebo는 물리 실험실"이라고 개념으로는 알고 있었는데, 둘을 동시에 띄워서 직접 비교해봤다.

핵심은: Gazebo가 유일하게 물리를 계산하는 주체(source of truth)고, RViz2는 그 결과(TF)를 구독만 하는 "거울"이라는 것. 그래서 둘을 같이 켜도 물리 계산이 두 번 일어나는 게 아니라, RViz2는 그냥 Gazebo가 계산한 결과를 그대로 비추기만 한다.

② 컨트롤러 종류: position vs velocity vs effort

지금까지 계속 position(목표 각도) 명령만 썼는데, ros2_control에는 세 가지 명령 인터페이스가 있다는 걸 정리했다.

  position velocity effort
명령 내용 목표 각도 목표 각속도 목표 토크(힘)
실제로 쓰이는 곳 산업용 로봇팔(오늘까지 쓴 것) 바퀴 기반 이동 로봇(Nav2) 힘 조절이 필요한 그리퍼, 두 발 밸런싱

오늘 쓴 팔 예제는 "이 각도로 가라"만 명령하면 충분해서 position이 딱 맞았다. 최종 목표(방 안 가사 로봇)로 갈수록 바퀴/다리로 이동하는 부분엔 velocity, 물건을 쥐는 힘 조절이나 두 발로 서서 균형 잡는 부분엔 effort가 필요해진다 — position/velocity만으로는 "얼마나 세게 쥘지"를 표현할 방법이 없기 때문이다.

가속도는 이 셋과는 다른 축이라는 것도 짚고 넘어갔다. 궤적의 각 경유점(JointTrajectoryPoint)에 positions뿐 아니라 velocities, accelerations까지 채워주면, 컨트롤러가 그 지점에서 멈추지 않고 부드럽게 지나가는 궤적을 계산해준다 — command_interface를 바꾸는 문제가 아니라 궤적을 얼마나 자세히 지정해주느냐의 문제였다.

③ 방 형태의 커스텀 world

최종 목표가 "방 안 가사를 해주는 로봇"이다 보니, 빈 허공 대신 방 안에서 팔을 움직여보고 싶었다. worlds/room.world를 SDF로 새로 작성했다 — 4m×4m 방, 벽 4개(남쪽에 폭 1m 출입구를 남겨서 나중 Nav2 이동 테스트 때 문 통과 시나리오로 쓸 계획), 테이블 역할의 박스 하나(나중 조작 단계에서 팔이 다룰 물체 자리).

④ launch 파일 견고화

Phase 2-1의 launch 파일은 TimerAction(period=4.0)처럼 "4초 있으면 컨트롤러가 다 뜨겠지"라고 고정된 시간을 추측해서 다음 단계를 실행했다. 컴퓨터가 그날따라 느리면 깨질 수 있는 방식이라, RegisterEventHandler(OnProcessExit(...))로 바꿨다. "몇 초"가 아니라 "앞 프로세스가 실제로 끝나는 이벤트"를 감지해서 다음 걸 실행하는 방식이라, 속도에 상관없이 안정적으로 동작한다.

spawn_entity 종료 → joint_state_broadcaster 로드
                        ↓ 종료
                                        joint_trajectory_controller 로드

⑤ (덤) 계속 반복 이동시키기

여기까지 하고 나니 "왕복 한 번 하고 끝나는 게 아쉽다, 계속 움직이게 할 순 없냐"는 질문이 나왔다. trajectory_loop_demo.py를 추가했다 — 궤적을 보내는 액션 자체는 그대로 두고, 같은 목표를 Ctrl+C할 때까지 while 루프 안에서 계속 재전송하는 구조다.

⑥ (덤) 터미널 하나로 합치기

Gazebo 켜는 터미널과 반복 이동을 실행하는 터미널이 두 개로 나뉘는 게 불편해서, launch 파일에 run_demo:=true 옵션을 추가했다. ④에서 쓴 이벤트 체이닝을 그대로 재사용해서, 컨트롤러가 활성화되는 순간 반복 이동까지 같은 launch 프로세스 안에서 자동으로 시작되게 만들었다.

ros2 launch fk_2link_demo gazebo_room.launch.py run_demo:=true

터미널 하나, Ctrl+C 한 번으로 Gazebo/RViz2/컨트롤러/반복이동이 전부 함께 뜨고 함께 정리된다. 실제로 돌아가는 모습을 짧게 녹화해봤다.

rviz_demo.mp4
3.43MB

통신 개념 복습

이 김에 ROS2 통신 방식 세 가지(토픽/서비스/액션)와 DDS도 다시 정리했다.

  • 토픽: 발행자가 채널에 계속 뿌리고, 구독자는 콜백 걸어놓고 받는 끝없는 스트림.
  • 서비스: 짧고 즉각적인 요청 → 응답. 중간 피드백이나 취소는 없음.
  • 액션: "오래 걸리는 작업"을 위한 방식. 목표 요청 → 수락/거부(서비스처럼) → 진행 중 피드백(토픽처럼) → 최종 결과, 순서로 동작하고 중간에 취소도 가능하다. 결국 서비스+토픽을 조합한 상위 개념인 셈.
  • DDS: 이 모든 통신의 밑바닥 규격. ROS1의 roscore 같은 중앙 서버 없이, 노드가 뜨는 순간 자동으로 서로를 찾아 연결된다(디스커버리). 그래서 터미널을 여러 개 띄워도 IP 설정 없이 알아서 통신되는 것.

다음 편 (Phase 3) 예고

인지(CV/YOLO) — 카메라로 방 안의 물체를 인식하는 단계로 넘어간다. 오늘 만든 테이블 박스가 그 첫 대상이 될 것 같다.

방 안 가사를 대신 해주는 휴머노이드 로봇을 만들겠다는 목표로 ROS2 공부를 시작했다. Phase 0(기구학)과 Phase 1(ROS2 코어: 노드/토픽/서비스/액션/파라미터/TF/URDF)을 지나, 이번엔 Phase 2 — 시뮬레이션이다.

Phase 1까지는 RViz2로 로봇 모형을 "그려서" 보는 게 전부였다. Phase 2의 목표는 그 로봇을 실제 물리엔진(Gazebo) 안에 존재하게 만들고, 진짜로 움직여보는 것. 오늘(2-1)은 여기까지 — 2관절 평면팔 하나를 Gazebo에 띄우고 실제로 움직이는 데까지 성공했다. 이 글은 그 과정에서 정리한 용어들과, 생각보다 많았던 삽질 기록이다.

왜 RViz만으론 안 되는가

Phase 1에서 썼던 RViz2와 Phase 2의 Gazebo는 역할이 완전히 다르다.

  RViz2 (Phase 1) Gazebo (Phase 2)
정체 시각화 도구 — 그림만 그림 물리 시뮬레이터 — 중력/충돌/관성을 실제로 계산
관절 각도의 출처 코드로 "이 각도다"라고 직접 우겨넣음 컨트롤러가 명령 → 물리엔진이 실제로 움직인 결과
URDF에 필요한 정보 visual만 있으면 됨 visual + collision + inertial까지 필요

RViz는 "그림판"이고 Gazebo는 "물리 실험실"이라고 생각하면 편하다. 그래서 Phase 1의 URDF를 그대로는 못 쓰고, 질량·관성·충돌 형상을 추가해야 했다.

핵심 용어 정리

이번 단계에서 새로 등장한 용어가 많아서, 이해한 순서대로 정리해본다.

URDF (Unified Robot Description Format)

로봇의 구조 — 링크가 몇 개고 어떻게 연결되는지(joint), 각 링크의 모양(visual/collision)과 질량·관성(inertial)이 뭔지 — 를 기술하는 XML 표준 포맷. RViz, Gazebo, MoveIt 같은 여러 ROS 도구가 이 하나의 "설계도"를 공통으로 읽는다.

Xacro (XML Macro)

URDF를 편하게 짜기 위한 전처리기. 매크로/변수/반복문으로 XML을 간결하게 쓸 수 있게 해주는 게 원래 목적인데, 이번엔 딱 하나의 이유로만 썼다 — Gazebo 플러그인 설정 안에서 $(find 패키지명) 같은 패키지 경로 치환을 쓰려면 xacro 프로세서를 거쳐야 하기 때문. 그래서 확장자만 .urdf.urdf.xacro로 바꿨다.

<gazebo>
  <plugin filename="libgazebo_ros2_control.so" name="gazebo_ros2_control">
    <parameters>$(find fk_2link_demo)/config/controllers.yaml</parameters>
  </plugin>
</gazebo>

$(find fk_2link_demo)는 "fk_2link_demo라는 패키지가 실제로 설치된 경로가 어디냐"를 xacro가 빌드 시점에 찾아서 절대경로로 바꿔주는 부분이다. 여기서 "패키지"는 공식 ROS2 패키지든 내가 만든 패키지든 차별이 없다.

Gazebo

물리 시뮬레이터. URDF(정확히는 Gazebo 내부 포맷인 SDF로 변환된 것)를 받아서 모델을 만들고, 중력·충돌·마찰·관성을 실제로 계산해 움직인다. 그냥 3D 뷰어가 아니라 물리 엔진이라는 게 핵심이다.

robot_description 토픽 vs /joint_states 토픽

둘 다 robot_state_publisher가 다루는 토픽인데 성격이 정반대다.

  • robot_description: URDF 텍스트 그 자체(구조). 정적 — 로봇이 움직여도 안 바뀐다. robot_state_publisher가 발행하고, RViz의 로봇 모델도 Gazebo의 spawn_entity.py도 다 이 토픽을 구독해서 로봇 모양을 알아낸다.
  • /joint_states: 지금 각 관절이 몇 도인지. 동적 — 계속 바뀐다. robot_state_publisher는 이걸 반대로 구독해서 TF를 계산한다.

spawn_entity.py

gazebo_ros 패키지에 원래 들어있는 표준 스크립트(내가 짠 코드가 아니다). /robot_description 토픽을 구독해서 URDF를 읽고, Gazebo에게 "이 로봇을 시뮬레이션에 넣어줘" 하고 서비스 요청을 보내는 역할을 한다.

ros2_control / controller_manager

실제 하드웨어(모터)든 시뮬레이션이든 상관없이 "컨트롤러"라는 표준화된 방식으로 로봇을 제어할 수 있게 해주는 프레임워크. controller_manager가 이 프레임워크의 핵심 관리자 노드로, 어떤 컨트롤러를 로드하고 활성화할지 관리한다.

gazebo_ros2_control 플러그인

URDF의 <gazebo><plugin> 태그로 로드되는 플러그인. Gazebo를 "하드웨어"처럼 취급해서 ros2_control과 이어주는 다리 역할을 한다.

joint_state_broadcaster

제어는 전혀 안 하고, 지금 관절 상태(position/velocity)를 읽어서 /joint_states로 내보내기만 하는 "리포터" 컨트롤러. Phase 1의 joint_publisher.py가 하던 역할을, 이번엔 진짜 시뮬레이션 값으로 대신해준다.

joint_trajectory_controller

진짜로 "제어"하는 쪽. FollowJointTrajectory라는 액션으로 목표 궤적(각도 + 도달 시간)을 받으면, 그 시간 동안 부드럽게 보간(interpolation)하면서 매 순간의 목표 각도를 계산해 Gazebo에 명령으로 내려보낸다.

여기서 중요한 구분 — joint_trajectory_controller는 물리 시뮬레이션을 직접 하지 않는다. "지금 몇 도쯤 가있어야 하는지 계산하는 뇌"고, 그걸 실제로 실행하는 "몸"은 Gazebo 물리엔진이다.

액션(Action)이란

ROS2의 통신 방식 중 하나로, 목표(goal)를 보내고 중간 피드백을 받다가 최종 결과(result)를 받는 구조. 오늘 만든 trajectory_demo.py가 액션 클라이언트로 목표를 보내고, joint_trajectory_controller가 액션 서버로 그 목표를 받아서 처리한다.

TF (Transform) — Phase 1 복습

로봇의 여러 좌표 프레임(base_link, upper_arm_link, forearm_link, hand_link) 사이의 위치·자세 관계를 실시간으로 계산하는 시스템. robot_state_publisherrobot_description(구조) + /joint_states(각도)를 조합해서 계산해준다.

전체 아키텍처 흐름

1. xacro가 URDF 처리 → 최종 URDF 문자열 생성
2. Gazebo(gzserver+gzclient) 실행
3. robot_state_publisher가 URDF를 /robot_description 토픽으로 발행
4. spawn_entity.py가 그걸 읽어서 Gazebo에 로봇 생성 요청
5. Gazebo가 모델 생성하면서 gazebo_ros2_control 플러그인 로드
   → 이때 controller_manager 노드가 새로 생성됨
6. spawner가 controller_manager에게 컨트롤러 활성화 요청
   (joint_state_broadcaster, joint_trajectory_controller 순서로)
7. trajectory_demo.py가 joint_trajectory_controller에 궤적 목표 전송
   → Gazebo 물리엔진이 실제로 관절을 움직임
   → joint_state_broadcaster가 결과를 /joint_states로 다시 발행
   → robot_state_publisher가 TF 계산 (Phase 1과 동일한 결말)

오늘 만든 것

기존 Phase 1의 fk_2link_demo 패키지를 새로 만들지 않고 그대로 확장했다.

  • urdf/two_link_arm.urdf.xacro — Phase 1 URDF에 collision/inertial/ros2_control 블록/gazebo_ros2_control 플러그인 추가
  • config/controllers.yaml — joint_state_broadcaster + joint_trajectory_controller 설정
  • launch/gazebo.launch.py — Gazebo 실행 → 로봇 스폰 → 컨트롤러 순서대로 로드
  • fk_2link_demo/trajectory_demo.py — 0° → (40°, -25°) → 0° 궤적을 실제로 보내는 액션 클라이언트

삽질 기록 — 에러 3종 세트

여기가 사실 오늘 제일 많은 시간을 쓴 부분이다. 각각 원인이 꽤 배울 게 많았다.

① 콜론(:) 하나가 파서를 깨뜨렸다

Gazebo를 켜면 로봇은 스폰되는데, 컨트롤러가 영원히 안 떴다. ros2 node list를 찍어보니 gazebo_ros2_control 노드는 있는데 controller_manager 노드가 없었다. 로그:

[ERROR] [gazebo_ros2_control]: parser error Couldn't parse parameter override rule:
'--param robot_description:=<?xml version="1.0" ?>...'

gazebo_ros2_control 플러그인이 URDF 전체를 --param robot_description:=<XML> 형태의 문자열로 재조립해서 자기 노드에 넘기는데, 이 값을 YAML 파서가 해석한다. xacro 파일에 달아둔 한글 설명 주석 중 "1) <inertial> : 질량 + 관성모멘트." 처럼 "콜론(:) + 공백"이 들어간 부분이 있었는데, YAML에서 이건 "여기서 key: value로 나눠라"는 신호라서 파서가 완전히 깨진 거였다.

고친 방법: 시뮬레이션에는 주석이 필요 없으니, robot_description 문자열을 넘기기 전에 정규식으로 XML 주석을 통째로 잘라내고 줄바꿈도 한 줄로 정리했다.

robot_description = re.sub(r'<!--.*?-->', '', robot_description, flags=re.DOTALL)
robot_description = ' '.join(robot_description.split())

원인이 URDF 구조가 아니라 내가 쓴 주석의 문장부호였다는 게 이 디버깅의 포인트.

② 고정 안 된 로봇 — 자유낙하하는 팔

에러는 없어졌는데, 가만히 있어도 관절 값이 미세하게 계속 흔들리고, 궤적을 실행하면 복귀할 때 갑자기 원점을 넘어가버리는 데다 그 정도가 실행할 때마다 달랐다.

원인은 URDF에 base_link를 어디에도 고정하는 조인트가 없었던 것. Gazebo 입장에서는 로봇 전체가 "중력으로 바닥에 떨어져서 접촉(contact)으로만 버티고 있는 자유물체"였다. 접촉 물리는 원래 재현성이 낮아서, 가만히 있어도 미세하게 재정착하며 떨리고, 팔이 빠르게 움직이면 그 반작용이 매번 다르게 나타난 것.

world라는 가상의 고정 링크를 만들고 fixed 조인트로 base_link를 용접하듯 고정해서 해결했다.

<link name="world"/>
<joint name="world_to_base" type="fixed">
  <parent link="world"/>
  <child link="base_link"/>
  <origin xyz="0 0 0.1" rpy="0 0 0"/>
</joint>

③ 내가 만든 회귀 — 바닥에 파묻힌 팔

②를 고치면서 "이제 떨어질 일 없으니까" 하고 스폰 높이(-z 인자)를 0.05에서 0.0으로 낮췄는데, 그러자 팔(두께가 있는 box 모양 링크)의 중심이 정확히 바닥 높이(z=0)에 놓이면서 두께 절반이 바닥 아래로 파고들었다. Gazebo가 "바닥을 뚫었다"고 감지하고 계속 밀어내는데 밀어내자마자 또 파고드니, 화면에서는 팔이 꺾인 채로 부들부들 떠는 것처럼 보였다.

해결은 스폰 인자가 아니라 world_to_base 조인트의 origin xyz0 0 0.1(바닥 위 10cm)로 줘서, 팔 전체가 애초에 바닥과 안 겹치는 높이에서 시작하게 만드는 것.

정리하면: ① 고정 안 된 자유낙하 로봇 → world에 고정, ② 고정하는 과정에서 높이를 잘못 낮춰 바닥에 파묻힘 → 높이 재조정. 한 문제를 고치다 다른 문제를 만든 전형적인 케이스였다.

결과 확인

ros2 launch fk_2link_demo gazebo.launch.py   # 터미널 1
ros2 control list_controllers                 # 터미널 2 — 두 컨트롤러 active 확인
ros2 run fk_2link_demo trajectory_demo        # 터미널 3 — 실제로 움직여보기

Gazebo 창에서 팔이 3초 동안 40°/-25° 자세로 움직였다가, 3초 더 있다가 원점으로 부드럽게 돌아오면 성공. ros2 topic echo /joint_states로 값이 실시간으로 바뀌는 걸 봐도 되고, rqt_plot으로 그래프로 그려서 보면 스크롤이 너무 빨라(controller_manager가 100Hz로 도는 중이라 초당 100번 발행됨) 눈으로 못 쫓는 문제도 해결된다.

다음 편 (Phase 2-2) 예고

  • RViz2 + Gazebo 동시에 띄워서 TF 비교
  • 컨트롤러 종류 이해 (오늘 쓴 position 방식 말고 velocity, effort 방식은 뭐가 다른지)
  • 방 형태의 커스텀 world 만들어보기 (최종 목표인 "방 안 가사 로봇" 시나리오에 조금씩 가까워지는 중)

오늘 한 일

지난번엔 2관절 평면팔의 순기구학(FK)을 파이썬으로 직접 계산했었다 (fk_2link_arm.py). 동차변환 행렬을 손으로 곱해서 손끝 좌표를 구하는 방식이었는데, 이번 Phase 1에서는 같은 걸 ROS2 방식으로 다시 만들어봤다. 목표는 간단하다 — 파이썬으로 계산했던 값(θ1=40°, θ2=-25°)을 그대로 ROS2 노드가 발행하고, RViz2가 알아서 순기구학을 계산해서 그려주는지 확인하는 것.

패키지 이름은 fk_2link_demo.

ROS2의 핵심 통신 방식: 토픽(Topic)

ROS2 노드끼리 데이터를 주고받는 방법 중 가장 기본이 되는 게 토픽(Topic)이다. 발행자(Publisher)가 특정 이름의 채널에 메시지를 계속 쏘고, 구독자(Subscriber)는 그 채널을 구독해두면 메시지가 올 때마다 자동으로 콜백이 호출되는 구조다. 발행자와 구독자는 서로의 존재를 몰라도 되고, 토픽 이름과 메시지 타입만 맞으면 통신이 성립한다.

이번에 만든 두 노드가 정확히 이 관계다.

1) joint_publisher.py — 관절 각도를 계속 쏘는 쪽

self.publisher_ = self.create_publisher(JointState, 'joint_states', 10)
self.timer = self.create_timer(0.1, self.publish_joint_states)

self.theta1 = math.radians(40)
self.theta2 = math.radians(-25)

def publish_joint_states(self):
    msg = JointState()
    msg.header.stamp = self.get_clock().now().to_msg()
    msg.name = ['shoulder_joint', 'elbow_joint']
    msg.position = [self.theta1, self.theta2]
    self.publisher_.publish(msg)

/joint_states라는 토픽에 10Hz로 같은 자세(40°/-25°)를 계속 발행한다. msg.name은 URDF에 정의된 조인트 이름과 반드시 일치해야 한다는 게 포인트 — 이름이 다르면 아무리 값을 보내도 로봇은 그 값을 자기 관절과 연결짓지 못한다.

2) joint_state_listener.py — 받아서 찍는 쪽

self.subscription = self.create_subscription(
    JointState,
    'joint_states',
    self.listener_callback,
    10,
)

def listener_callback(self, msg):
    angles_deg = [math.degrees(p) for p in msg.position]
    pairs = ", ".join(f'{name}={deg:.1f}deg' for name, deg in zip(msg.name, angles_deg))
    self.get_logger().info(f'받음: {pairs}')

여기서 신기했던 부분은, listener_callback을 내가 직접 호출하는 코드가 코드 어디에도 없다는 것. create_subscription으로 등록만 해두면 메시지가 도착할 때마다 ROS2 런타임이 알아서 이 함수를 불러준다. 콜백 기반 구조를 코드로 직접 체감한 첫 순간이었다.

robot_state_publisher가 대신 계산해주는 것

/joint_states를 발행하기만 하면, robot_state_publisher가 URDF에 적힌 링크 구조(base_link → upper_arm_link → forearm_link → hand_link)를 보고 TF(좌표 변환)를 자동으로 계산해서 뿌려준다. 즉 예전에 내가 손으로 짰던 동차변환 행렬 곱셈 체인

T_base_shoulder · T_shoulder_forearm · T_forearm_hand

을, URDF + /joint_states만 주면 ROS2가 대신 해주는 셈이다.

검증: 파이썬 계산값이랑 실제로 맞는지 확인

RViz2를 띄우면 팔이 40°/-25° 자세로 서 있는 게 보이는데, 눈으로 보는 것 말고 숫자로도 확인해봤다.

ros2 run tf2_ros tf2_echo base_link hand_link

결과로 나온 Translation의 x, y 값이 예전 파이썬 스크립트 결과인 x=0.4713, y=0.2575와 거의 일치했다. 손으로 짠 동차변환 체인 곱셈과 ROS2의 URDF+TF 계산이 같은 답을 낸다는 걸 직접 검증한 셈이라 확실히 이해가 됐다.

전체 TF 트리는 이렇게 뽑아볼 수도 있다.

ros2 run tf2_tools view_frames

오늘 정리

  • ROS2 토픽 통신 = Publisher/Subscriber 패턴, 이름과 메시지 타입만 맞으면 서로 몰라도 통신 성립
  • create_publisher / create_subscription + 콜백 자동 호출 구조
  • robot_state_publisher가 URDF + /joint_states만으로 TF(순기구학)를 자동 계산
  • 파이썬으로 손으로 계산했던 FK 결과와 ROS2가 계산한 결과가 일치하는 걸 tf2_echo로 직접 검증

다음 Phase 2에서는 이 팔을 RViz2 위의 "그림"이 아니라 Gazebo 물리 시뮬레이터 안에 실제로 존재하게 만들고, 컨트롤러로 진짜 움직여볼 예정이다.

- 레벨 블루프린트를 사용한 오브젝트 사이의 통신

레벨에 존재하는 액터 선택후 레벨 블루프린트에서 우클릭하면 레퍼런스를 얻을 수 있다.

 

- 블루프린트 안에서 컴포넌트 사이의 통신

블루프린트에서 다른 블루프린트 오브젝트 레퍼런스를 프로퍼티로 저장하고 디테일 패널에서 할당

 

- 블루프린트 인터페이스를 사용하여 서로 다른 타입의 오브젝트 간에 원활하게 통신하기

 

블루프린트에서 블루프린트 인터페이스 함수를 호출 하는 것은 메세지 호출과 같다.

구현이 안되어 있으면 아무 일도 일어나지 않는다.

 

여러 항목이 한 이벤트를 처리해야 할 경우 Event Dispatcher를 사용

예) 보스가 죽었을 때 미니언이 같이 죽게 만들기

 

Event Dispatcher = C++에서 델리게이트 역할을 한다

GameMode 블루프린트에 두어 item Updated 델리게이트로 사용할 수 있다.

 

게임모드 내의 이벤트 (런타임) 에서 맵에 존재하는 액터의 레퍼런스를 얻고 싶은 경우

GetActor 류 함수를 사용 (Tag, Class 사용), 태그를 사용하는 경우 액터 태그와 컴포넌트 태그는 별개이므로 주의해야 한다.

런타임에서 액터를 찾는 함수는 느리므로 자주 사용하면 안된다. (Tick 내에서 호출 X)

 

'언리얼 5 > 에픽 게임즈 공식 강의' 카테고리의 다른 글

Gameplay  (0) 2023.02.21
Programming 2  (0) 2023.02.21
Enhanced Input Binding with Gameplay Tags C++  (0) 2023.02.21
Geometry Script  (0) 2023.02.20
Engine Structure  (0) 2023.02.20

- Game Instance

프로그램 시작 ~ 종료 내내 유지되는 오브젝트

리플리케이트되지 않음. 클라이언트와 서버 각각에 독립적으로 존재

레벨을 옮겨도 유지하고싶은 것들을 여기에 배치 (SaveGame 시스템 같은 것들)

 

- Game Mode

맵 로드시 가장 먼저 생기는 매니저 액터

서버에만 존재

멀티플레이어 게임에서 이미 만들어진 세션에 새로운 플레이어가 참여하면 GameMode가 폰 컨트롤러와 State를 만들어준다. 

 

- Game State

Player State 배열을 갖는다.

게임 상태에 대한 정보 저장

서버와 클라이언트에 리플리케이트된다.

 

- Player State

특정 플레이어에 대한 정보 저장

서버와 클라이언트에 리플리케이트된다.

 

캐릭터, 컨트롤러, 폰

- Physics

Pawns은 Kinetic 액터이기 때문에 무한한 질량을 가진다.

dynamic interaction에서 interactor만 영향을 받게된다.

Pawn에게도 영향을 주려면 추가 로직이 필요하다. (HitResult 정보를 사용하기)

 

- Default Pawn

Character의 형제 클래스

Default Pawn : 스태틱 메시 컴포넌트, Sphere 콜리전, Floating Pawn Movement 컴포넌트

Movement가 리플리케이트 되지 않음

 

- Spectator Pawn

Default Pawn의 서브 클래스

콜리전 X, Static Mesh X, 투명 카메라만 존재하는 Pawn

 

 

- Character Movement Component

네트워크에서 레플리케이트 됨

다양한 Movement Mode 보유 (walking jumping falling flying swimming

physics Interaction : physics가 movement에 어떻게 영향을 줄 것인가

 

- Movement Capability 

길찾기 관련 옵션 파라미터 (Non Player Controller의 경우)

캐릭터의 능력 (앉기, 점프, 수영하기) 활성화 여부

 

 

- Player Controller

Player Controller는 클라이언트에서 서버로 레플리케이트된다.

유저 Input과 일반적인 player pawn 관련 logic을 관리한다.

Input Latency를 최소화하기 위해 Pawn보다 먼저 Tick 된다. 

 

- AI Controller 

서버에만 존재한다. 

 

- MassEntity System프레임워크

효율적으로 대량의 데이터를 처리하게 해줌 

 

- Navigation

모바일 캐릭터에 다이나믹 NavMesh를 사용하는 것은 퍼포먼스 문제가 있다.

Pathfinding True 설정하고 AI 캐릭터에게 특정 장소로 이동하게 시키면 Navmesh를 사용해 길을 찾아서 이동한다.

 

- HUD 

리플리케이트되지 않음

플레이어 컨트롤러가 HUD 액터를 스폰함

'언리얼 5 > 에픽 게임즈 공식 강의' 카테고리의 다른 글

Blueprint Communication  (0) 2023.02.22
Programming 2  (0) 2023.02.21
Enhanced Input Binding with Gameplay Tags C++  (0) 2023.02.21
Geometry Script  (0) 2023.02.20
Engine Structure  (0) 2023.02.20

 

모든 C++ 코드(엔진, 프로젝트) 는 모듈에 속해 있다.

 

Unreal Build Tool 은 Cross-Platform 빌드 시스템이다.

 

각각의 모듈은 DLL로 컴파일된다.

 

플러그인은 모듈/ 애셋의 컬렉션이다.

플러그인을 사용하면 다른 프로젝트로 쉽게 코드를 공유할 수 있고 패키징하여 마켓플레이스에 올릴 수도 있다.

 

Feature Plugin

런타임에 활성화할 수 있는 Feature

 

Platform Extension

플랫폼 전용 코드를 독립시킨다.

 

 

UObject를 참조하는 모든 프로퍼티는 UPROPERTY 레퍼런스 이거나 Weak포인터 타입이어야 한다.

 

Struct는 Blueprint 함수를 가질 수 없다.

 

FString : 기본 String, Mutable

 

FName : global map에서 문자열을 해쉬/캐쉬 한다. FName은 단순히 인덱스를 저장하기 때문에 빠른 비교연산이 가능하다. 이름과 경로에 적합하다.

 

FText : 현지화 지원 가능한 문자열, 엔진의 Localization 시스템에서 지원한다. user face 텍스트에 사용된다.

 

LifeCycle

새로운 UObject 할당 : NewObject 함수

새로운 액터 : SpawnActor 함수

 

UObject는 다른 오브젝트들에 의해 참조되지 않으면 자동으로 제거된다.

액터는 Destroy 함수에 의해 제거되고 Garbage 컬렉터에 의해 메모리에서 제거된다.

 

UPROPERTY 매크로 없이 UObject를 만드는 경우 가비지 컬렉터에 보이지 않게 된다.

오브젝트가 컬렉팅 되었을 때 포인터가 메모리의 invalid 데이터를 가리키게 될 수 있다.

 

UObject를 가리키는 포인터

TObjectPtr

TWeakObjectPtr

TSubclassOf

TSoftPtr

TSoftClassPtr

 

UObject를 가리키지 않는 포인터

TUniquePtr

TSharedPtr

TSharedRef

TWeakPtr

 

인터페이스는 C++과 Blueprint에서 선언 가능하다. 블루프린트에서 선언된 인터페이스는 다른 블루프린트에서만 사용 가능하기 때문에 C++에서 선언하기가 권장된다.

 

- Static Function Library

특정 UObject에 속하지 않는 함수, Struct에 기능을 추가할 때 좋은 방법이다.

 

Data Object

데이터 조직, 로딩 방법을 제공한다.

- Data Asset, Data Table, Curve

 

- Delegate & Event

C++에서 이벤트를 구독하고 싶다면 이벤트가 구현되어 있어야 한다.

 

- Subsystem

기존의 클래스를 수정하지 않고도 기능을 확장할 수 있게 해준다.

전역에서 접근할 수 있다.

 

- Async Task Graph

다수의 CPU core에서 실행시키고 싶은 작업하고 싶을때 사용한다.

내부적으로 Thread pool을 사용한다.

 

 

맵 로드나 소프트 레퍼런스를 사용한 Object 로드 요청은 사용자에 의해 핸들링 가능하다.

하드 레퍼런스는 parent Asset에 의해 재귀적으로 로드된다.

 

하드 레퍼런스는 편리하지만 어떤 한 애셋이 프로젝트 나머지 애셋을 모두 참조하는 상황이 만들어지면 메모리와 로드 시간에 문제가 생길 수 있다.

 

가비지 컬렉터는 레퍼런스되지 않거나 Pending Kill 상태인 오브젝트를 1분에 한번씩 제거한다. 이 기능은 오버라이드 될 수 있다.

 

가비지 컬렉터는 Mark & Sweep 메서드를 사용한다. 멀티 쓰레드 작업이며 1프레임에 진행된다. 하지만 실제 Object 파괴는 여러 프레임에 걸쳐 진행된다.

 

- GameMode

레벨마다 존재하는 Object

멀티 게임에서는 서버에만 존재

레벨 변화 시 초기화되므로 데이터 저장에 적합하지는 않음

 

- GameInstance

애플리케이션 라이프타임을 가짐

global singleton이어서 전역에서 접근 가능

 

- GameState

서버에서 클라이언트로 Replicated됨

게임 정보

 

- PlayerState

서버에서 클라이언트로 Replicated 됨

개인 정보

 

- PlayerController

플레이어 컨트롤러는 소유한 클라이언트와 서버에만 존재한다.

 

- Movement Component

다양한 무브먼트 모드 지원

예측을 사용하기 떄문에 온라인에서도 지원되는 기능

 

- Cheat Manager

콘솔 커맨드를 사용하여 기능을 구현할 수 있다.

Ship할때는 지워짐

 

- Ticking

두 그룹으로 나눌 수 있다. Physics 도중, Physics 전

Transform이나 Collision을 사용하지 않는다면 Physics 도중에 틱하는게 좋다.

오브젝트마다 Tick rate, Tick dependency, Tick 활성화 여부를 결정할 수 있다.

Tick은 싱글 스레드로 실행되기 때문에 게임 성능에 중요하다.

매 프레임 Tick할 필요가 없는 오브젝트에 대해서는 Tick Frequency를 줄이자.

 

- Gameplay Ability System

Gameplay Ability를 다루는 멀티플레이어 지원 프레임워크

 

- Mass

많은 수의 개체에 대한 행동을 State Graph를 사용하여 분산 실행하여 다루는 방법

 

- Game Feature 

런타임에서 게임 기능을 켜고 끌 수 있는 플러그인

게임 모드를 컴포넌트 처럼 사용하는 것?

 

 

- 액터 리플리케이션

UProperty 혹은 컴포넌트 전체를 리플리케이트 할 수 있다.

 

- RepNotify

리플리케이트 설정한 변수 값이 바뀌었을 때 로직을 실행할 수 있게 해준다.

 

- OwnerShip

액터를 소유한 플레이어 컨트롤러만이 서버에 데이터를 보낼 수 있다.

서버는 모두에게 데이터를 보낼 수 있다.

 

- Replication Graph 플러그인

어떤 것이 다른 멀티플레이어에게 영향을 줄 것인지에 대한 규칙을 정할 수 있다.

예를 들면 Distance, Visibility 등

어떤 플레이어와 관련 있다고 간주되는 액터만 서버에서 클라이언트로 리플리케이트 하게 해준다.

대규모 멀티플레이어 게임에서 효율적으로 동작할 수 있다.

 

- Remote Procedure Calls (RPCs)

다른 클라이언트에 있는 함수를 호출할 수 있게 해준다.

RPC를 Reliable 혹은 Unreliable 하게 설정할 수 있다.

 

https://forums.unrealengine.com/t/difference-between-property-replication-and-rpc-technically/755737

 

Difference between Property Replication and RPC technically

Property Replication and RPC both replicate data between server and clients. And I have some questions about them. When they replicated? Is it end of tick or something? Is there bandwidth difference between two?

forums.unrealengine.com

 

언리얼 엔진 전용 툴, 플랫폼 전용 툴 모두 프로젝트에서 실행 가능하다.

 

- Visual Logger

Timeline Based 로깅

이벤트를 위한 디버그 비주얼과 메타데이터를 녹화한다.

별도의 구성 없이 네트워크 리플리케이션 로그도 지원한다.

 

- Console 

커맨드를 호출하거나 cVars를 수정하여 Feature를 활/비활성화 하여 Cost를 테스트할 수 있다.

나만의 콘솔 함수/변수를 만들 수 있다.

 

- Unreal Insights

Session Frontend 내부에 있던 나머지 기능들이 이것으로 대체됨

에디터 툴 메뉴에서 실행된다.

이전 툴에 없던 추가적인 기능으로 모든 스레드에서 일어나는 이벤트를 자세하게 보여준다.

메모리 할당 및 각각의 네트워크 패킷을 분석할 수 있다.

CPU, TPU 분석도 가능

에디터와 패키지 빌드 모두에서 사용할 수 있다. (Shipping Build 제외)

프로파일링 데이터를 저장하고 리플레이할 수 있다.

네트워크를 통해 다른 컴퓨터나 장치에 remote Profiling도 가능하다.

Instrumentation 매크로를 코드에 추가하여 세분화된 프로파일링을 할 수 있다.

 

- ProfileGpu

Console Command 한 프레임의 timing을 조사

 

- DumpGPU

한 프레임에서 사용된 모든 리소스들을 Interactive한 html 페이지를 사용하여 조사할 수 있게 해줌

draw Call, internal 버퍼 포함

 

- Gauntlet

빌드된 프로젝트를 실행하고 검증

멀티 플랫폼 자동화 테스트 용도

'언리얼 5 > 에픽 게임즈 공식 강의' 카테고리의 다른 글

Blueprint Communication  (0) 2023.02.22
Gameplay  (0) 2023.02.21
Enhanced Input Binding with Gameplay Tags C++  (0) 2023.02.21
Geometry Script  (0) 2023.02.20
Engine Structure  (0) 2023.02.20

Native Gameplay Tag Setup

Tag는 Native하게 정의되어 있으므로 Input Binding을 위해 참조할 수 있다. 또한 Editor에서 입력 구성을 위해 Data Asset에서도 사용할 수 있다.

싱글톤 구조체 FMyGameplayTags를 정의하고 초기화 시 NativeTag를 등록하는 로직을 구현한다.

 

MyGameplayTags.h

MyGameplayTags.cpp

 

Asset Manager

Native 게임플레이 Tag를 초기화하기 좋은 장소이다. 

StartInitialLoading 함수를 오버라이딩하고 여기서 태그를 초기화한다.

프로젝트 세팅에서 Asset Manager를 MyAssetManager로 설정하자.

 

MyAssetManager.h

MyAssetManager.cpp

 

Input Configuration

에디터의 input Action와 input tag를 연관지을 방법으로 Data asset을 사용한다. 

UInputAction과 FGameplayTag 페어를 가지는 단순한 구조체 (FTaggedInputAction) 를 사용하고, 구조체를 블루프린트에 이 타입을 노출시킨다.

Data Asset은 FtaggedInputAction의 배열을 가질 것이고 태그를 사용하여 InputAction을 찾는 헬퍼 함수도 갖게 된다.

 

InputConfig.h

InputConfig.cpp

 

Enhanced Input Component

커스텀 Enhanced Input Component : UEnhancedInputComponent::BindAction Wrapper 함수 (BindActionByTag) 소유

MyEnhancedInputComponent.h

 

Character Setup

헤더파일 : 캐릭터에 InputConfig 애셋 가리키는 포인터 추가, 동작 함수 추가.

 

구현파일 : SetupPlayerInputComponent 함수를 오버라이딩하여 캐릭터의 동작 함수와 InputAction을 연관된 게임플레이 태그를 사용하여 bind 한다.

 

Input Settings

기본 Player Input Class를 EnhancedPlayerInput으로 지정

기본 Input Component Class를 MyEnhancedInputComponent로 지정

 

Input Assets and Configuration

Input Action은 추상적인 게임 액션을 나타낸다. 

Input Action들은 bool, float vector와 같은 데이터 타입을 반환하는데, 이것들은 값을 수정하거나 트리거 규칙을 구성한다.

 

트리거 규칙은 언제 어떻게 조건을 통해 액션을 발동시킬 것인지 결정한다.

Pressed 트리거는 한번의 입력 동안 이벤트를 한 번 발생시키고 Pulse 트리거는 키 입력 동안 일정 간격으로 이벤트를 발생시킨다.

Modifier는 Input Action의 최종 출력을 조정하는 데 적용될 수 있다. 예를 들면 Dead Zone Modifier는 입력 변수를 상한선과 하한선 사이로 잘라준다.

 

Input Mapping Context

하드웨어 Input - Input Action을 매핑

플레이어가 탈것을 타는 경우에 새로운 InputMapping Context가 적용될 것이다. 

 

키보드와 마우스의 경우 바이너리 Key이므로 0-1 값을 기록한다.

Move의 경우 W와 S키에 Swizzle modifier를 적용해 1D input을 2D 액션의 Y 축에 매핑해야 한다.

A와 S에는 Negate modifier를 적용해 음수 값을 가지도록 한다.

 

 

Character Blueprint

1. 캐릭터 블루프린트에서 InputConfig 프로퍼티에 IC_BaseCharacter 데이터 애셋을 세팅

2. OnBeginPlay 이벤트에서 EnhancedInputLocalSubsystem 노드를  사용하여 Input Mapping Context 애셋을 등록한다.

3. 캐릭터를 GameMode에서 기본 Pawn으로 지정한다.

 

'언리얼 5 > 에픽 게임즈 공식 강의' 카테고리의 다른 글

Gameplay  (0) 2023.02.21
Programming 2  (0) 2023.02.21
Geometry Script  (0) 2023.02.20
Engine Structure  (0) 2023.02.20
All about Soft and Weak pointer  (0) 2023.02.20

+ Recent posts