언리얼 자율주행 차량, 곡률 반경으로 안전 속도 계산하기 (V = √(μgR))

자율주행 차량을 구현하다 커브에서 어떻게 감속할지 정해야 하는 순간이 온다. "휘어 있으면 느리게, 직선이면 빠르게" 같은 if/else로 시작했다가 곧 한계에 부딪힌다. 어떤 커브는 너무 천천히 들어가고, 어떤 커브는 너무 빠르게 들어가 미끄러진다. 곡률 값(라디안) 자체를 임의의 상수로 속도에 매핑하려고 하면 그 상수를 어떤 기준으로 정해야 할지 감이 안 잡힌다.

물리 공식을 한 번 쓰면 이 문제가 깔끔하게 풀린다. 차량이 원형 곡선 위에서 미끄러지지 않을 조건은 원심력 ≤ 마찰력이고, 이걸 정리하면 한계 속도는 다음과 같다.

V = √(μ × g × R)

 

  • μ (마찰 계수): 노면 상태 (일반 도로 0.7~0.8, 빙판 0.1)
  • g (중력 가속도): 9.8 m/s²
  • R (곡률 반경): 그 지점에서 도로가 그리는 가상의 원의 반지름

이 글은 스플라인에서 측정한 곡률(라디안)에서 R을 뽑아내는 과정과, 그 결과를 한계 속도로 변환하는 과정을 담는다.

 

곡률(라디안)에서 곡률 반경(cm)으로

스플라인에서 곡률을 측정하는 방식은 두 지점에서의 도로 방향 사이 각도 차이를 보는 것이다(이 부분은 다른 글에서 따로 다뤘다).

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

 

여기서 반환되는 값은 두 지점 사이 도로 진행 방향의 각도 차이(라디안)다. 그리고 두 지점은 스플라인 위에서 50cm 떨어져 있다.

이 두 값을 호의 길이 공식에 대입하면 곡률 반경이 나온다.

호의 길이 = 반지름 × 각도
L = R × θ

 

스플라인의 한 구간을 원의 일부로 근사하면 다음 대응이 성립한다.

  • 호의 길이 L: 두 측정 지점 사이 도로 거리 = 50cm
  • 각도 θ: 두 지점에서 도로 방향의 각도 차이 = 측정한 곡률(라디안)
  • 반지름 R: 그 원의 반지름 = 구하려는 값

식을 R에 대해 풀면 이렇게 된다.

R = L / θ = 50cm / Curvature

 

곡률(각도)이 작을수록(직선에 가까울수록) R이 커지고, 곡률이 클수록(급커브일수록) R이 작아진다. 직관과도 일치한다.

 

공식을 코드로

float ComputeCurveSpeedLimit(float Curvature) const
{
    if (Curvature <= KINDA_SMALL_NUMBER)
        return MaxSpeed;

    const float Radius = CurvatureSampleSpan / Curvature;

    return FMath::Sqrt(LateralFriction * 980.f * Radius);
}

 

세 부분으로 나뉜다.

직선 처리. Curvature가 거의 0이면 곡률 반경이 무한대에 수렴하고, 그대로 나누면 0으로 나누기 사고가 난다. KINDA_SMALL_NUMBER(0.0001f) 이하면 직선으로 간주하고 최대 속도를 반환한다. 부동소수점 비교에서 == 0을 쓰지 않는 이유는 오차 때문에 정확히 0이 되지 않을 수 있어서다.

곡률 반경 계산. 호의 길이 공식 그대로다. CurvatureSampleSpan은 곡률을 측정할 때 쓴 두 지점 사이 도로 거리(50cm)다. 이 값은 EstimateCurvature 함수와 한 쌍으로 맞춰야 한다. 측정 간격을 바꿨는데 여기서 안 바꾸면 결과가 어긋난다.

한계 속도 계산. V = √(μ × g × R) 그대로다.

 

단위가 중요하다 (g = 980)

이 부분에서 한 번 실수했다. 언리얼은 기본 단위가 cm다. Radius도 cm로 구해진다. 그런데 공식의 g를 9.8(m/s²)로 두면 단위가 어긋나서 결과가 실제보다 작게 나온다. 한계 속도가 비정상적으로 낮게 잡혀서 차가 직선에서도 굼떴다.

언리얼 좌표계에서는 g = 980 cm/s²로 쓰는 것이 맞다.

return FMath::Sqrt(LateralFriction * 980.f * Radius);

 

결과로 나오는 한계 속도도 자동으로 cm/s 단위가 된다. 이걸 km/h로 표시하고 싶다면 × 0.036을 곱하면 된다(예: 1000 cm/s = 36 km/h).

 

최소/최대 속도 클램프

이 공식만으로 두면 두 가지 문제가 남는다. 곡률이 거의 없는 도로에서 한계 속도가 비현실적으로 높게 나오고, 급커브에서 너무 낮게 나와 차가 멈출 정도로 느려진다. 양쪽을 클램프로 막는다.

return FMath::Clamp(
    FMath::Sqrt(LateralFriction * 980.f * Radius),
    MinSpeed,
    MaxSpeed
);

 

MinSpeed는 14km/h 정도(400 cm/s)에서 시작했다. 너무 낮으면 차가 거의 정지해서 시뮬레이션이 답답해진다. MaxSpeed는 차량 성능과 도로 종류에 맞춰 잡는다. 둘 다 UPROPERTY로 빼두면 도로마다 다르게 설정할 수 있다.

 

마찰 계수 튜닝

LateralFriction은 노면 상태에 대응하는 값이다. 실제 물리값과 정확히 일치시킬 필요는 없고, 시뮬레이션상 차량이 자연스럽게 보이는 값을 튜닝으로 찾는 편이 빠르다. 이 프로젝트에서는 0.6 정도에서 시작했다.

이 값을 도로 메타데이터로 두면 "빙판 도로", "젖은 도로" 같은 시나리오에서 같은 공식 그대로 한계 속도가 자동으로 낮아진다. 별도 조건 분기를 추가할 필요가 없다.

const float Friction = TargetRoad->SurfaceFriction;
return FMath::Sqrt(Friction * 980.f * Radius);

 

전방 곡률로 미리 감속

현재 위치의 곡률만 보면 커브 진입 직전에 급제동을 하게 된다. 전방 N미터 앞의 곡률도 함께 측정하고, 둘 중 더 제한적인 속도를 채택하면 미리 감속이 자연스러워진다.

const float CurvHere = EstimateCurvature(0.f);
const float CurvAhead = EstimateCurvature(BrakePreviewDist);

const float SpeedLimit = FMath::Min(
    ComputeCurveSpeedLimit(CurvHere),
    ComputeCurveSpeedLimit(CurvAhead)
);

 

FMath::Min으로 더 작은 쪽을 고르는 패턴이라 전방에 급커브가 있으면 지금부터 감속이 시작된다. BrakePreviewDist는 차량 속도에 비례해 조절하면 더 자연스럽다(빠를수록 멀리 미리 봐야 한다).

 

정리

곡률 값(라디안)을 임의의 상수로 속도에 매핑하려 하지 말고, 물리 공식 한 줄을 쓰면 튜닝할 값이 자연스럽게 정리된다.

  • 호의 길이 공식 L = R × θ로 곡률(라디안)을 곡률 반경(cm)으로 변환
  • V = √(μ × g × R)로 한계 속도 계산
  • 언리얼에서는 g = 980 (cm/s²)
  • 0으로 나누기 방지를 위해 KINDA_SMALL_NUMBER 비교
  • 최소/최대 속도는 Clamp로 보장
  • 전방 곡률과 현재 곡률 둘 중 더 제한적인 쪽을 FMath::Min으로 채택

이 패턴은 자율주행 시뮬레이션뿐 아니라 레이싱 게임 AI, 비행체 곡선 비행, 카메라 트랙 속도 조절 등 "곡선 위에서 안전한 속도를 정해야 하는" 모든 상황에 그대로 응용된다.

+ Recent posts