[DAY42] Taming the Drone with PID Control

베리투스·2025년 10월 1일

TIL: Today I Learned

목록 보기
43/93

[DAY 42] Taming the Drone with PID Control

어제는 C++로 드론의 6-DOF 움직임을 직접 구현했다. 오늘은 여기서 한 단계 더 나아가, 드론이 스스로 목표 고도를 유지하는 "자동 호버링" 기능에 도전했다. 이를 위해 메카트로닉스 전공 수업에서 이론으로만 배웠던 PID 제어를 언리얼 엔진에 직접 구현해보았다. 단순히 힘을 가하는 것을 넘어, '오차'를 기반으로 드론의 움직임을 안정화시키는 과정이 정말 흥미로웠다. 🚁


📌 목표

  • P 제어: 목표를 향해 움직이지만 출렁이는 드론 만들기.
  • PD 제어: 드론의 출렁임(진동)을 잡아 부드럽게 안착시키기.
  • PID 제어: 미세한 잔여 오차를 제거해 목표 고도에 완벽히 고정하기.
  • 게인 튜닝: Kp, Ki, Kd 값을 조절하며 최적의 제어 성능 찾아보기.

📖 이론

PID 제어는 마치 세 명의 전문가가 각자의 역할에 따라 드론을 조종하는 것과 같았다.

  1. P 제어 (Proportional, 비례): "현재만 보는 저돌적인 행동파"

    • 오직 "현재 목표와의 거리(오차)"만 보고 힘을 결정한다.
    • 목표에서 멀면 강하게, 가까우면 약하게 힘을 가한다.
    • 반응이 즉각적이지만, 힘 조절을 못 해서 목표 지점을 계속 지나치는 진동(Oscillation)을 일으킨다.
  2. D 제어 (Derivative, 미분): "미래를 예측하는 침착한 분석가"

    • "오차가 얼마나 빠르게 변하는지(속도)"를 보고 미래를 예측한다.
    • 목표에 너무 빠르게 접근하면 미리 힘을 줄여주는 "브레이크" 역할을 한다.
    • P 제어의 단점인 진동과 오버슈트를 잡아 시스템을 안정화시킨다.
  3. I 제어 (Integral, 적분): "과거를 기억하는 꼼꼼한 완벽주의자"

    • 아주 작은 오차라도 "과거부터 얼마나 쌓여왔는지"를 모두 기억하고 계산한다.
    • 중력 등으로 인해 발생하는 미세한 "정상상태 오차"를 제거하여 정확성을 높인다.
    • 목표에 완벽히 도달하도록 마지막 힘을 보태주는 역할을 한다.

💻 코드

MyDronePawn.h

#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;
};

MyDronePawn.cpp

#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) 값을 무작정 동시에 조절했다.

    • 처음부터 Kp, Ki, Kd 값을 한 번에 바꾸며 최적값을 찾으려고 했다. 하지만 이건 거의 불가능에 가까웠다. "Kp → Kd → Ki 순서"로 하나씩 값을 잡아가야 각 제어기의 역할을 명확히 보며 튜닝할 수 있었다.
  • D 제어와 I 제어의 역할을 반대로 알고 있었다.

    • 나는 D 제어가 시스템을 '민첩하게' 만들고, I 제어가 '안정적이게' 만든다고 착각했다. 하지만 실제로는 정반대였다. D 제어는 움직임을 억제하는 브레이크 역할로 "안정성"을 높였고, I 제어는 누적된 힘 때문에 오히려 시스템을 불안정하게 만들 수 있는 "정확성" 담당이었다.
  • AddForce를 썼는데 드론이 움직이지 않았다.

    • 분명 PID 계산을 통해 AddForce로 힘을 가하는 코드를 넣었는데 드론이 미동도 하지 않았다. 원인은 간단했다. 드론의 StaticMeshComponent 속성에서 "SetSimulatePhysics(true)"를 호출하지 않아 물리 엔진의 영향을 받지 않고 있었던 것이다.

✅ 핵심 요약

개념설명비고
P (비례) 제어현재 오차에 비례해 힘을 가하는 '핵심 동력'저돌적인 행동대장
I (적분) 제어과거부터 쌓인 오차를 기반으로 '정상상태 오차'를 제거꼼꼼한 완벽주의자
D (미분) 제어오차의 변화율(속도)을 예측해 진동을 억제하는 '브레이크'침착한 분석가
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글