어제는 C++로 드론의 6-DOF 움직임을 직접 구현했다. 오늘은 여기서 한 단계 더 나아가, 드론이 스스로 목표 고도를 유지하는 "자동 호버링" 기능에 도전했다. 이를 위해 메카트로닉스 전공 수업에서 이론으로만 배웠던 PID 제어를 언리얼 엔진에 직접 구현해보았다. 단순히 힘을 가하는 것을 넘어, '오차'를 기반으로 드론의 움직임을 안정화시키는 과정이 정말 흥미로웠다. 🚁
PID 제어는 마치 세 명의 전문가가 각자의 역할에 따라 드론을 조종하는 것과 같았다.
P 제어 (Proportional, 비례): "현재만 보는 저돌적인 행동파"
D 제어 (Derivative, 미분): "미래를 예측하는 침착한 분석가"
I 제어 (Integral, 적분): "과거를 기억하는 꼼꼼한 완벽주의자"
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "MyDronePawn.generated.h"
UCLASS()
class YOURPROJECT_API AMyDronePawn : public APawn
{
GENERATED_BODY()
public:
AMyDronePawn();
protected:
virtual void BeginPlay() override;
public:
virtual void Tick(float DeltaTime) override;
private:
// 드론의 외형과 물리 시뮬레이션 담당
UPROPERTY(VisibleAnywhere)
UStaticMeshComponent* DroneMesh;
// --- PID 제어 변수 (에디터에서 튜닝) ---
// 목표: 도달하고자 하는 고도
UPROPERTY(EditAnywhere, Category = "PID Controller | Settings")
float TargetAltitude = 1000.0f;
// P 게인: 현재 오차에 대한 반응 강도
UPROPERTY(EditAnywhere, Category = "PID Controller | Gains")
float Kp = 100.0f;
// I 게인: 누적된 오차에 대한 반응 강도
UPROPERTY(EditAnywhere, Category = "PID Controller | Gains")
float Ki = 10.0f;
// D 게인: 오차의 변화율에 대한 반응 강도 (제동)
UPROPERTY(EditAnywhere, Category = "PID Controller | Gains")
float Kd = 50.0f;
// --- PID 내부 계산용 변수 ---
// D 제어용: 이전 프레임의 오차 저장
float LastError = 0.0f;
// I 제어용: 오차의 총합 저장
float ErrorIntegral = 0.0f;
// 바닥까지의 거리를 측정하는 함수
float GetCurrentAltitude() const;
};
#include "MyDronePawn.h"
#include "Components/StaticMeshComponent.h"
#include "DrawDebugHelpers.h"
AMyDronePawn::AMyDronePawn()
{
// 매 프레임 Tick 함수가 호출되도록 설정
PrimaryActorTick.bCanEverTick = true;
// 기본 컴포넌트 생성 및 루트로 설정
DroneMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("DroneMesh"));
RootComponent = DroneMesh;
// 물리 시뮬레이션 활성화 (AddForce 사용의 필수 조건)
DroneMesh->SetSimulatePhysics(true);
// 언리얼 엔진의 기본 중력 사용
DroneMesh->SetEnableGravity(true);
}
void AMyDronePawn::BeginPlay()
{
Super::BeginPlay();
// 게임 시작 시, 제어에 사용될 변수 초기화
LastError = 0.0f;
ErrorIntegral = 0.0f;
}
void AMyDronePawn::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
// 1. 상태 측정: 현재 고도를 라인 트레이스로 가져오기
const float CurrentAltitude = GetCurrentAltitude();
if (CurrentAltitude < 0.0f) // 바닥 감지 실패 시 제어 중단
{
return;
}
// 2. 오차 계산: (목표 - 현재)
const float Error = TargetAltitude - CurrentAltitude;
// 3. P(비례) 제어 계산: 현재 오차에 비례하는 힘
const float P_Term = Kp * Error;
// 4. I(적분) 제어 계산: 시간에 따라 오차를 계속 누적
ErrorIntegral += Error * DeltaTime;
const float I_Term = Ki * ErrorIntegral;
// 5. D(미분) 제어 계산: 오차의 변화 속도(미래 예측)
const float ErrorDerivative = (Error - LastError) / DeltaTime;
const float D_Term = Kd * ErrorDerivative;
// 현재 프레임의 오차를 다음 프레임 계산을 위해 저장
LastError = Error;
// 6. 최종 제어력 산출: P, I, D 힘을 모두 합산
const float UpwardForce = P_Term + I_Term + D_Term;
// 7. 힘 적용: 계산된 힘을 드론의 Z축(위쪽)으로 가하기
DroneMesh->AddForce(FVector(0.0f, 0.0f, UpwardForce));
}
float AMyDronePawn::GetCurrentAltitude() const
{
FVector StartLocation = GetActorLocation();
// 충분히 긴 거리를 아래로 탐색 (100미터)
FVector EndLocation = StartLocation - FVector(0.0f, 0.0f, 10000.0f);
FHitResult HitResult;
// 자기 자신은 충돌 검사에서 제외
FCollisionQueryParams CollisionParams;
CollisionParams.AddIgnoredActor(this);
// 라인 트레이스 발사
if (GetWorld()->LineTraceSingleByChannel(HitResult, StartLocation, EndLocation, ECC_Visibility, CollisionParams))
{
// 바닥을 찾았을 경우, 시작점부터 충돌 지점까지의 거리를 반환
return HitResult.Distance;
}
// 바닥을 찾지 못했을 경우, 유효하지 않은 값(-1)을 반환
return -1.0f;
}
이론은 이해했지만 막상 구현하니 생각처럼 쉽지 않았다. 오늘 내가 겪었던 실수들이다.
게인(Gain) 값을 무작정 동시에 조절했다.
D 제어와 I 제어의 역할을 반대로 알고 있었다.
AddForce를 썼는데 드론이 움직이지 않았다.
AddForce로 힘을 가하는 코드를 넣었는데 드론이 미동도 하지 않았다. 원인은 간단했다. 드론의 StaticMeshComponent 속성에서 "SetSimulatePhysics(true)"를 호출하지 않아 물리 엔진의 영향을 받지 않고 있었던 것이다.| 개념 | 설명 | 비고 |
|---|---|---|
| P (비례) 제어 | 현재 오차에 비례해 힘을 가하는 '핵심 동력' | 저돌적인 행동대장 |
| I (적분) 제어 | 과거부터 쌓인 오차를 기반으로 '정상상태 오차'를 제거 | 꼼꼼한 완벽주의자 |
| D (미분) 제어 | 오차의 변화율(속도)을 예측해 진동을 억제하는 '브레이크' | 침착한 분석가 |