단순한 첫 시도
if (도로가 휘어져 있음) {
Throttle = 0.3; // 천천히
} else {
Throttle = 1.0; // 빠르게
}
도로가 휘어져 있음 -> 곡률(curvature) 측정 함수 사용
float EstimateCurvature(float AheadOffset) const
{
const FVector D1 = GetDirectionAtDistance(CurrentDistance + AheadOffset);
const FVector D2 = GetDirectionAtDistance(CurrentDistance + AheadOffset + 50.f);
return FMath::Acos(FMath::Clamp(FVector::DotProduct(D1, D2), -1.f, 1.f));
}
이게 라디안 단위 각도를 반환함
- 직선: 0에 가까움
- 살짝 곡선: 0.1 정도
- 급커브: 1.0 이상
곡률을 어떻게 속도로 바꾸지?
처음 떠오른 방식
// 단순 반비례
const float MaxSpeed = 3000.f;
float TargetSpeed = MaxSpeed - Curvature * 1000.f;
동작은 하는데...
- 어떤 커브는 너무 천천히 감
- 어떤 커브는 너무 빠르게 들어가서 미끄러짐
- 값(1000.f)을 어떻게 정해야 할지 감이 안 옴
이거 아닌 것 같음;;
AI 도움
"한계 속도(critical speed)"
차량이 곡선 도로에서 미끄러지지 않고 지나갈 수 있는 최대 속도.
물리 공식 사용
원운동에서 차가 미끄러지지 않으려면:
원심력 ≤ 마찰력
공식 이용해서 최대 안전 속도 구하면

필요한 건:
- 마찰 계수 (μ): 보통 도로 0.7~0.8, 빙판 0.1, 레이싱 타이어 1.2
- 곡률 반경 (R): 도로가 얼마나 휘었는지
최대 안전 속도 = √(마찰계수 × 중력 × 커브반지름)
- 도로가 끈끈할수록 → 빨리 가도 됨
- 커브가 클수록(완만할수록) → 빨리 가도 됨
- 급커브 + 빙판 → 매우 천천히
곡률 반경 = 그 지점에서 도로가 그리는 가상의 원의 반지름
직선 도로: 반지름 = 무한대
완만한 커브: 반지름 = 큼
급커브: 반지름 = 작음
곡률 측정할 때 도로 위에서 50cm 떨어진 두 지점을 고르고 각 지점에서 도로 방향을 가져옴
그 50cm가 CurvatureSampleSpan

중요한 거: 빨간 선(50cm)은 도로를 따라간 거리
도로가 휘어 있으면 빨간 선도 휘어 있음

빨간 도로는 회색 점선 원의 일부분
그 원의 반지름이 구하려는 R!
- 원이 크면 → 도로가 거의 직선처럼 보임 (완만한 커브)
- 원이 작으면 → 도로가 많이 휘어 있음 (급커브)
이제 두 개념 합치기
- 두 지점 사이 도로를 따라간 거리 = 50cm (이게 호의 길이)
- 두 지점에서 도로 방향의 각도 차이 = 곡률 (라디안)
호의 길이 = 반지름 × 각도
L = R × θ
대입
50cm = R × Curvature
그러니까
R = 50cm / Curvature
- 빨간 호 = 두 지점 사이 도로 = 50cm (CurvatureSampleSpan)
- 각도 θ = 두 반지름 사이 각도 = 측정한 곡률(라디안)
- 파란 R = 가상의 원 반지름 = 구하려는 값
L = R × θ
50cm = R × Curvature
R을 구하면
R = 50cm / Curvature
R = CurvatureSampleSpan / Curvature
각도(작은 값)로 50cm를 나누면 반지름이 나옴
- 각도가 작으면 (직선에 가까우면) → 50cm를 작은 수로 나눔 → R이 매우 큼 → 직선
- 각도가 크면 (급커브) → 50cm를 큰 수로 나눔 → R이 작음 → 급커브
float ComputeCurveSpeedLimit(float Curvature) const
{
if (Curvature <= KINDA_SMALL_NUMBER)
return MaxSpeed; // 직선이면 최대 속도
// 곡률 → 곡률 반경
const float Radius = CurvatureSampleSpan / Curvature;
// V = √(μ × g × R)
return FMath::Sqrt(LateralFriction * 980.f * Radius);
}
주의: 언리얼은 cm 단위라 g = 9.8 m/s² 가 아니라 980 cm/s²
이거 모르고 m 단위로 계산하면 결과가 100배 이상 작아짐
KINDA_SMALL_NUMBER
KINDA_SMALL_NUMBER = 0.0001f
부동소수점에서 "사실상 0인지" 확인할 때 쓰는 값
Curvature == 0으로 비교하면 부동소수점 오차 때문에 위험함
대신 Curvature <= 0.0001f로 비교
1. 직선 처리
if (Curvature <= KINDA_SMALL_NUMBER)
return MaxSpeed;
Curvature가 거의 0이면 (직선이면) 0으로 나누기 사고가 남
그래서 직선이면 그냥 최대 속도 반환
2. 곡률 → 반지름
const float Radius = CurvatureSampleSpan / Curvature;
방금 본 호의 길이 공식
CurvatureSampleSpan(50cm)를 곡률(라디안)로 나눠서 반지름(cm) 얻기
3. 최대 속도 계산
return FMath::Sqrt(LateralFriction * 980.f * Radius);
물리 공식 그대로
V = √(μ × g × R)
= √(LateralFriction × 980 × Radius)
원심력 = 마찰력 조건에서 질량이 약분돼서 V = √(μ·g·R) 만 남고, 호의 길이 공식 L = R·θ로 곡률(각도)을 반지름으로 바꾼다. 단위(cm)만 조심하면 끝.
최소/최대 속도 제한
return FMath::Clamp(
FMath::Sqrt(LateralFriction * 980.f * Radius),
MinSpeed, // 너무 천천히 가도 답답하니 최저 속도 보장
MaxSpeed // 직선이라도 너무 빠르면 위험
);
MinSpeed = 400 (4m/s = 14km/h 정도)로 두면 안 막힘.
새 문제 발견
1. 너무 늦게 감속 -> 전방 곡률 미리 측정
// 현재 위치 곡률
const float CurvHere = EstimateCurvature(0.f);
// 전방 50m 앞의 곡률
const float CurvAhead = EstimateCurvature(5000.f); // 50m 앞
// 둘 중 더 위험한 쪽 채택
const float SpeedLimit = FMath::Min(
ComputeCurveSpeedLimit(CurvHere),
ComputeCurveSpeedLimit(CurvAhead)
);
FMath::Min으로 더 작은(=더 제한적인) 속도를 선택
이러면 50m 앞에 급커브가 있으면 지금부터 감속 시작
| 지금 | 50m 앞 | 결과 |
| 직선 (빠름 OK) | 직선 (빠름 OK) | 빠르게 달림 |
| 직선 (빠름 OK) | 급커브 (느려야) | 미리 감속 |
| 급커브 (느려야) | 직선 (빠름 OK) | 일단 느림 |
| 급커브 (느려야) | 급커브 (느려야) | 느림 |
(참고) FMath::Min - 그냥 작은 거 고르는 함수
FMath::Min(3, 7) // → 3
FMath::Min(50, 80) // → 50
변수화해서 튜닝 가능하게
UPROPERTY(EditAnywhere)
float BrakePreviewDist = 5000.f; // 50m
const float CurvAhead = EstimateCurvature(BrakePreviewDist);
5000(50m)이 항상 좋은 값은 아님
- 빠른 차는 더 멀리 봐야 함
- 느린 차는 가까이 봐도 됨
- 트랙별로 다를 수도 있음
그래서 에디터에서 조정 가능하게 변수화한 것
2. 속도가 갑자기 변함 -> 부드러운 가/감속 함수 적용
float FInterpTo(float Current, float Target, float DeltaTime, float InterpSpeed);
현재 값에서 목표 값으로 매 프레임 조금씩 다가가게 만들어주는 함수
지수 함수처럼 처음엔 빠르게 가까워지면 천천히
멤버 변수로 "스무딩된 목표 속도" 추가
private:
float SmoothedTargetSpeed = 0.f;
Tick에서 부드럽게 보간
// 진짜 목표 속도 (곡률 기반)
const float SpeedLimit = FMath::Min(
ComputeCurveSpeedLimit(CurvHere),
ComputeCurveSpeedLimit(CurvAhead)
);
// 부드럽게 다가감 (1초에 절반씩 가까워지는 정도)
SmoothedTargetSpeed = FMath::FInterpTo(
SmoothedTargetSpeed,
SpeedLimit,
DeltaTime,
0.5f // InterpSpeed
);
- 감속(브레이크): 안전을 위해 천천히, 부드럽게
- 가속(스로틀): 빠르게 회복
→ 두 경우에 다른 보간 속도 적용:
const float Rate = (SpeedLimit < SmoothedTargetSpeed) ? DecelRate : AccelRate;
SmoothedTargetSpeed = FMath::FInterpTo(SmoothedTargetSpeed, SpeedLimit, DeltaTime, Rate);
UPROPERTY(EditAnywhere)
float DecelRate = 0.5f; // 감속은 천천히
UPROPERTY(EditAnywhere)
float AccelRate = 1.0f; // 가속은 빠르게
이제 이걸 실제 페달 조작으로 바꾸기
- 현재 속도 < 목표 속도 → 가속해야 함 (스로틀)
- 현재 속도 > 목표 속도 → 감속해야 함 (브레이크)
- 거의 같음 → 페달 모두 떼기 (관성)
P 컨트롤러
P 컨트롤러란?
P는 Proportional(비례). 이름 그대로: 차이가 크면 많이 밟고, 차이가 작으면 살짝 밟아라.
명령 = (목표 - 현재) × Gain
- 목표 - 현재 = 차이 (얼마나 부족한지)
- Gain = 곱하는 비율 (얼마나 강하게 반응할지)
const float Cmd = (SmoothedTargetSpeed - CurrentSpeed) * ThrottleGain;
- SmoothedTargetSpeed - CurrentSpeed = 차이 (양수면 더 빨라야, 음수면 너무 빠름)
- × ThrottleGain = 그 차이를 페달 명령으로 환산
Cmd 값:
- 양수 → 가속해야 함 (액셀)
- 음수 → 감속해야 함 (브레이크)
- 0 근처 → 그대로 유지
ThrottleGain은 적당히 작게 (예: 0.002)
너무 크면: 차이가 살짝만 나도 페달을 확 밟아버림. 목표를 지나치고, 다시 반대로 확 밟고 -> 차가 휘청거림
너무 작으면: 차이가 커도 페달을 살살만 밟음. 목표에 도달하는 데 한참 걸림. (반응 느림)
즉 너무 크면 진동, 너무 작으면 반응 느림
-1 ~ +1 범위로 클램프
const float Cmd = FMath::Clamp(
(SmoothedTargetSpeed - CurrentSpeed) * ThrottleGain,
-1.f, 1.f
);
- +1 = 액셀 100% 풀로 밟기
- 0 = 페달에서 발 떼기
- -1 = 브레이크 100% 풀로 밟기
차이가 엄청 클 때 (예: 목표 200km/h, 현재 0km/h) 계산값이 +5 같은 말도 안 되는 값이 나올 수 있음
페달은 100%가 최대인데 500% 밟을 수는 없으니 clamp 사용
데드존 추가 (0 근처는 무시)
문제: Cmd가 0.001 같은 작은 값이어도 페달이 살짝 밟힘 → 데드존(Deadzone) 도입
if (Cmd > 0.05f) {
// 충분히 큰 양수 → 가속
Throttle = Cmd;
}
else if (Cmd < -0.05f) {
// 충분히 큰 음수 → 감속
Brake = -Cmd;
}
else {
// 거의 0 → 페달 다 떼기
Throttle = 0;
Brake = 0;
}
"명령이 너무 작으면 그냥 무시하자."
- Cmd > 0.05 → 충분히 크니까 액셀 밟기
- Cmd < -0.05 → 충분히 (반대로) 크니까 브레이크 밟기
- -0.05 < Cmd < 0.05 → 의미 없는 값. 둘 다 떼기 (관성 주행)

0.05 임계값을 CoastDeadzone 변수로 빼서 튜닝 가능하게
도로별 제한 속도 추가
스쿨존이나 어린이보호구역 같은 시나리오를 위해 도로별 SpeedLimit 도 적용
곡률 계산만 하면 스쿨존 같은 직선 도로를 100km/h로 달릴 수도 있으니까
if (TargetRoad->SpeedLimit > 0.f)
{
SpeedLimit = FMath::Min(SpeedLimit, TargetRoad->SpeedLimit);
}
여러 제한이 있으면 가장 빡센 거(=가장 작은 거) 따라가기
ex.
| 80 (완만한 커브) | 60 (일반 도로) | 60 |
| 80 (완만한 커브) | 30 (스쿨존) | 30 |
| 40 (급커브) | 100 (고속도로) | 40 |
| 40 (급커브) | 30 (스쿨존) | 30 |
if (TargetRoad->SpeedLimit > 0.f)
모든 도로에 제한 속도가 정해져 있는 건 아니니까 제한 없는 도로일 떄
언리얼에서는 보통 제한 없음을 0이나 음수로 표시
- SpeedLimit = 0 → 제한 없음 → 곡률 한계만 적용
- SpeedLimit = 60 → 60km/h 제한 있음 → 둘 중 작은 거 적용
if (... > 0.f)로 "유효한 값일 때만" 적용하는 것
SpeedLimit = ComputeCurveSpeedLimit(CurvHere);
SpeedLimit = FMath::Min(SpeedLimit, ComputeCurveSpeedLimit(CurvAhead)); // 전방 곡률
SpeedLimit = FMath::Min(SpeedLimit, TargetRoad->SpeedLimit); // 도로 제한
// 더 추가한다면
// SpeedLimit = FMath::Min(SpeedLimit, RainSpeedLimit); // 비 올 때
// SpeedLimit = FMath::Min(SpeedLimit, TrafficAheadLimit); // 앞차 있을 때
Min을 계속 누적하는 패턴
"지금까지 본 모든 제한 중 가장 빡센 거"를 들고 가는 것
// SpeedLimit = 곡률 기반 한계
float SpeedLimit = FMath::Min(
ComputeCurveSpeedLimit(CurvHere),
ComputeCurveSpeedLimit(CurvAhead)
);
// 도로 자체 제한 속도가 있으면 추가 적용
if (TargetRoad->SpeedLimit > 0.f)
{
SpeedLimit = FMath::Min(SpeedLimit, TargetRoad->SpeedLimit);
}
결과 정리
// 매 프레임 실행
1. 현재 곡률 측정 → 안전 속도 계산
2. 전방 곡률 측정 → 미리 감속용 안전 속도 계산
3. 둘 중 더 제한적인 속도 선택
4. (있으면) 도로별 제한 속도 적용
5. 부드럽게 보간 (FInterpTo)
6. 목표 속도 vs 현재 속도 → 스로틀/브레이크 명령
7. 데드존 처리
물리 개념
- 원심력 vs 마찰력 : 차가 미끄러지는 원리
- 곡률 반경 : 1/곡률
- 마찰 계수 : 노면별로 다른 그립력
제어 개념
- P 컨트롤러 : 가장 단순한 비례 제어
- 데드존 : 작은 명령은 무시 (떨림 방지)
- 선행 제어 (Look-ahead) : 미래를 예측해서 미리 대응
언리얼 함수
| FMath::Sqrt | 제곱근 |
| FMath::FInterpTo | 부드러운 보간 |
| FMath::Min | 둘 중 작은 값 |
| FMath::Clamp | 값 범위 제한 |
| KINDA_SMALL_NUMBER | 0.0001 (부동소수점 비교용) |
'프로젝트 > 자율주행 위험구간 분석 시뮬레이터' 카테고리의 다른 글
| 파라미터 (0) | 2026.05.08 |
|---|---|
| 코드 정리와 확장 준비 (0) | 2026.05.08 |
| 조향 알고리즘 (0) | 2026.05.08 |
| 매 프레임 도로 따라가기 (0) | 2026.05.07 |
| 첫 설계와 도로 클래스 (0) | 2026.05.07 |
