자율주행 차량 조향 가중치를 곡률에 따라 동적 조정하기 (Lerp 활용)

스플라인 경로 추종 차량의 조향 식을 PositionError + HeadingError + CrossTrackError 형태로 짜고 나면, 다음으로 부딪히는 문제는 가중치 튜닝이다. 직선에서 부드럽게 가게 잡은 HeadingWeight = 0.7이 급커브에서는 차를 도로 안쪽으로 잘라먹게 만든다. 그렇다고 커브 기준으로 낮게 잡으면 직선에서 흔들린다.

해답은 가중치를 고정값으로 두지 않고 도로 형태에 따라 매 프레임 다르게 두는 것이다. 이 글은 그 동적 조정 로직을 정리한다.

 

고정 가중치의 한계

조향 식은 보통 두 오차의 가중 평균으로 구성된다.

YawCmd = PositionError * (1 - HeadingWeight) + HeadingError * HeadingWeight

 

이 식에서 HeadingWeight가 높으면 차체 방향을 도로 방향에 맞추는 데 더 비중을 둔다. 직선에서는 이쪽이 안정적이지만, 급커브에서는 차가 커브 진행 방향을 너무 빨리 따라가려고 해서 도로가 휘기 전에 미리 휘는 현상이 생긴다.

커브에서는 반대로 PositionError(어디로 가야 하는가)의 비중을 높여야 한다. 즉 도로 형태에 따라 HeadingWeight가 달라져야 한다는 뜻이다.

 

곡률을 한 줄로 측정하기

도로 형태를 판단하려면 곡률이 필요하다. 두 지점에서의 도로 진행 방향이 얼마나 다른지를 보면 된다.

float EstimateCurvature(float AheadOffset) const
{
    const FVector D1 = Spline->GetDirectionAtDistanceAlongSpline(
        CurrentDistance + AheadOffset, ESplineCoordinateSpace::World);
    const FVector D2 = Spline->GetDirectionAtDistanceAlongSpline(
        CurrentDistance + AheadOffset + 50.f, ESplineCoordinateSpace::World);

    return FMath::Acos(FMath::Clamp(FVector::DotProduct(D1, D2), -1.f, 1.f));
}

 

핵심은 단위벡터 두 개의 내적이 cos(각도)라는 점이다. 거기에 Acos를 씌우면 각도(라디안)가 복원된다.

  • 직선이면 두 방향이 거의 같음 → 내적 ≈ 1 → Acos ≈ 0
  • 살짝 곡선이면 내적이 약간 작아짐 → Acos가 작은 양수
  • 급커브면 내적이 더 작아짐 → Acos가 큰 양수

Clamp를 씌운 이유는 부동소수점 오차다. 두 단위벡터가 거의 같으면 내적이 1을 살짝 넘는 1.0000001 같은 값이 나올 수 있는데, Acos는 이 입력에서 NaN을 반환한다. -1~1로 잘라두면 안전하다.

 

곡률을 0~1로 정규화

EstimateCurvature가 반환하는 라디안 값은 보통 0.0 ~ 0.3 정도 범위에 들어온다. 이걸 가중치 계산에 바로 쓰기는 불편하니 0~1로 정규화한다.

const float CurvHere = EstimateCurvature(0.f);
const float CurvNorm = FMath::Clamp(CurvHere * 3.f, 0.f, 1.f);

 

3.f를 곱하는 이유는 0.33 라디안 정도면 충분히 급커브로 보고 CurvNorm을 1로 만들기 위해서다. 이 배율은 도로 곡률 분포에 따라 튜닝이 필요한 부분이다. 직선이 많은 고속도로면 더 크게(예: 5.f), 산악 도로면 더 작게 두는 식이다.

결과적으로:

  • 직선 → CurvNorm = 0
  • 적당한 곡선 → CurvNorm = 0.5 부근
  • 급커브 → CurvNorm = 1

 

Lerp로 가중치 보간

이제 CurvNorm을 가중치로 변환한다. Lerp 한 줄이면 된다.

const float HdgWeight = FMath::Lerp(0.7f, 0.3f, CurvNorm);

 

Lerp(A, B, t)는 t=0이면 A, t=1이면 B, 그 사이는 선형 보간이다.

  • 직선(CurvNorm = 0): HdgWeight = 0.7 → 방향 맞추기에 비중
  • 급커브(CurvNorm = 1): HdgWeight = 0.3 → 위치 맞추기에 비중
  • 중간(CurvNorm = 0.5): HdgWeight = 0.5 → 반반

조향 식은 그대로 두고 가중치만 매 프레임 달라진다.

const float HdgWeight = FMath::Lerp(0.7f, 0.3f, CurvNorm);
float YawCmd = PositionError * (1.f - HdgWeight) + HeadingError * HdgWeight;

 

전방 주시 거리도 같은 패턴으로

전방 주시 거리(Look-Ahead Distance)도 도로 형태에 따라 달라야 한다.

  • 직선: 멀리 봐야 안정적. 가까이만 보면 휘청거림.
  • 급커브: 가까이 봐야 정확. 멀리 보면 도로 안쪽을 잘라먹음.

같은 Lerp 패턴으로 처리할 수 있다.

const float CurvScale = FMath::Lerp(1.f, 0.5f, CurvNorm);
const float LookAheadDist = (LookAheadBase + Speed * 0.3f) * CurvScale;

 

LookAheadBase는 기본 거리, Speed * 0.3f는 빠를수록 더 멀리 보는 효과를 주는 항이다. 거기에 CurvScale을 곱해 직선에서는 그대로, 급커브에서는 절반 정도로 줄어들게 했다.

 

전체 조향 흐름

// 1. 곡률 측정 → 0~1 정규화
const float CurvHere = EstimateCurvature(0.f);
const float CurvNorm = FMath::Clamp(CurvHere * 3.f, 0.f, 1.f);

// 2. 가중치와 전방 주시 거리를 Lerp로 결정
const float HdgWeight = FMath::Lerp(0.7f, 0.3f, CurvNorm);
const float CurvScale = FMath::Lerp(1.f, 0.5f, CurvNorm);
const float LookAheadDist = (LookAheadBase + Speed * 0.3f) * CurvScale;

// 3. 각 오차 계산 (LookAheadDist를 인자로 사용)
// PositionError = ...
// HeadingError = ...
// CrossTrackError = ...

// 4. 블렌딩
float YawCmd = PositionError * (1.f - HdgWeight) + HeadingError * HdgWeight;
float Steering = FMath::Clamp(YawCmd / MaxYawDelta - CrossTrackError * CrossTrackGain, -1.f, 1.f);

 

조향 식 자체는 글 A에서 정리한 형태 그대로다.

달라진 건 가중치와 전방 주시 거리가 매 프레임 곡률에 따라 다시 계산된다는 점이다.

 

정리

조향 가중치를 고정값으로 두면 직선과 커브 중 한쪽은 반드시 어색해진다. 곡률을 매 프레임 측정하고, Lerp로 가중치와 전방 주시 거리를 함께 보간하면 한 세트의 파라미터로 양쪽을 다 커버할 수 있다.

핵심 도구는 세 가지다.

  • 단위벡터 내적 + Acos: 두 방향 사이 각도 복원 (곡률 근사)
  • Clamp로 0~1 정규화: 이후 단계에서 쓰기 좋은 형태로
  • Lerp: 직선/커브 양 끝값 사이를 부드럽게 전환

이 패턴은 자율주행 외에도 "환경에 따라 파라미터를 부드럽게 전환해야 하는 모든 상황"(난이도 조절, 카메라 거리, AI 공격성 등)에 그대로 응용된다.

+ Recent posts