언리얼 스플라인 경로 추종 차량 조향, Pure Pursuit + Heading + Cross-Track 블렌딩으로 정리한 과정

언리얼에서 스플라인 위를 따라가는 차량의 조향을 구현할 때, 가장 흔히 떠올리는 방법이 Pure Pursuit이다. "전방 한 점을 향해 핸들을 꺾는다"는 단순한 발상이고, 실제로 직선에서는 잘 동작한다. 하지만 단독으로 쓰면 차가 좌우로 흔들리고, 도로 중심선을 따라가지도 않는다.

이 글은 Pure Pursuit 하나만으로 부족한 이유와, Heading Error와 Cross-Track Error를 추가해 세 가지를 합친 조향 식으로 정리한 과정을 담는다.

단계 1. Pure Pursuit만 쓸 때의 한계

Pure Pursuit는 전방의 한 점(Look-Ahead Point)을 정하고, 차의 현재 방향과 그 점까지의 방향 사이 각도 차이로 핸들 값을 정한다.

const float LookAheadDistance = 1500.f;
FVector LookAheadPoint = Spline->GetLocationAtDistanceAlongSpline(
    CurrentDistance + LookAheadDistance,
    ESplineCoordinateSpace::World
);

FVector ToLookAhead = (LookAheadPoint - VehicleLocation).GetSafeNormal();
float TargetYaw = FMath::Atan2(ToLookAhead.Y, ToLookAhead.X) * (180.f / PI);

float PositionError = FMath::FindDeltaAngleDegrees(VehicleYaw, TargetYaw);
float Steering = FMath::Clamp(PositionError / MaxYawDelta, -1.f, 1.f);

 

이대로 돌리면 차가 좌우로 진동했다. 원인은 Pure Pursuit가 "목표 지점에 도달하는 것"만 보고 "도착했을 때 차체가 도로와 정렬되는지"는 보지 않기 때문이었다. 목표 지점에 사선으로 진입한 뒤 다시 반대편 목표를 향해 꺾기를 반복하면서 진동이 생긴다.

단계 2. Heading Error만 쓰면 또 다른 문제

차체가 도로 방향과 평행하게 유지되도록 하려면 Heading Error를 더할 수 있다. 전방 지점에서의 도로 진행 방향과 차의 현재 방향 사이 각도 차이다.

FVector LookAheadDirection = Spline->GetDirectionAtDistanceAlongSpline(
    CurrentDistance + LookAheadDistance,
    ESplineCoordinateSpace::World
);

float TargetHeading = FMath::Atan2(LookAheadDirection.Y, LookAheadDirection.X) * (180.f / PI);
float HeadingError = FMath::FindDeltaAngleDegrees(VehicleYaw, TargetHeading);

 

이걸 단독으로 쓰면 차체는 도로 방향과 평행해지지만, 차가 도로 옆 갓길에 빠져 있어도 그저 갓길을 평행하게 달릴 뿐 도로 중앙으로 돌아오지 않는다. 방향만 맞추기 때문에 위치 보정이 안 된다.

단계 3. 두 오차를 가중치로 블렌딩

PositionError는 "어디로 가야 하는가"를, HeadingError는 "어느 방향을 보고 가야 하는가"를 본다. 둘을 가중 평균으로 섞으면 서로의 약점을 보완할 수 있다.

const float HeadingWeight = 0.7f;
float YawCmd = PositionError * (1.f - HeadingWeight) + HeadingError * HeadingWeight;
float Steering = FMath::Clamp(YawCmd / MaxYawDelta, -1.f, 1.f);

 

이 프로젝트에서는 HeadingWeight = 0.7로 두는 것이 부드러웠다. 직선 도로에서는 방향을 맞추는 쪽 비중이 높을수록 진동이 줄어들기 때문이다. 다만 이 비율은 도로 형태에 따라 달라지는데, 그 부분은 곡률에 따른 동적 조정으로 풀 수 있다(다른 글에서 다룬다).

단계 4. Cross-Track Error로 중심선 복귀 보강

블렌딩을 했더니 직선에서는 안정적인데, 곡선에서 차가 도로 안쪽 라인으로 살짝 잘려나가는 현상이 남았다. 차가 도로 중심선에서 좌우로 얼마나 벗어났는지를 보는 항이 빠져 있어서 그렇다. 이걸 Cross-Track Error로 보강했다.

Cross-Track Error는 "차가 도로 중심선에서 수직으로 얼마나, 어느 쪽으로 벗어났는가"를 부호 있는 숫자로 표현한 값이다. 계산은 벡터 연산으로 풀리는데(외적과 내적), 그 자체로 분량이 있어서 별도 글로 분리했다. 여기서는 결과만 본다.

const float CrossTrackError = ComputeCrossTrackError();

float Steering = FMath::Clamp(
    YawCmd / MaxYawDelta - CrossTrackError * CrossTrackGain,
    -1.f, 1.f
);

 

CrossTrackError가 양수(오른쪽 이탈)면 핸들에서 양수가 빠져 왼쪽으로 꺾이고, 음수면 그 반대가 된다. 부호 처리가 자동으로 맞아 들어간다.

CrossTrackGain은 작은 값(이 프로젝트에서는 0.0015)으로 시작하는 것이 안전했다. CrossTrackError 단위가 cm라 숫자가 크게 나오는데, 게인을 크게 잡으면 보정이 과해져서 다시 좌우로 휘청이게 된다.

최종 조향 식 정리

// 1. 세 가지 오차
PositionError    = 차 방향 vs 전방 지점 방향
HeadingError     = 차 방향 vs 전방 지점에서의 도로 방향
CrossTrackError  = 도로 중심선에서의 수직 이탈 (부호 있음)

// 2. 방향 오차 두 개는 가중치로 블렌딩
YawCmd = PositionError * (1 - HeadingWeight) + HeadingError * HeadingWeight

// 3. 핸들 값으로 변환 + 횡방향 보정 빼기
Steering = clamp(YawCmd / MaxYawDelta - CrossTrackError * CrossTrackGain, -1, 1)

정리

Pure Pursuit는 직관적이지만 단독으로는 진동과 중심선 이탈을 모두 잡지 못했다. Heading Error로 차체 방향을, Cross-Track Error로 좌우 이탈을 보강해 세 항을 합친 형태로 정리하니 직선과 곡선 양쪽에서 안정적으로 동작했다.

이 식은 자율주행 시뮬레이션 외에도 "정해진 경로를 따라 움직이는 차량형 액터"라면 거의 그대로 쓸 수 있다. 각 가중치(HeadingWeight, CrossTrackGain)는 차량 속도와 도로 형태에 따라 튜닝이 필요한 부분이라 UPROPERTY로 빼두면 편하다.

+ Recent posts