Ace Combat Zero: 유니티로 구현하기 #2 : 비행기 조종하기

Lunetis·2021년 3월 6일
1

Ace Combat Zero

목록 보기
3/27
post-thumbnail

(이 프랜차이즈는 음악이 좋기로 소문났는데, 들어보시겠어요?)



#1에서는 비행기를 조종하기 위한 입력값을 받아냈습니다.
이제 이 값들을 이용해서 본격적으로 비행기를 조종해보겠습니다.


회전

이동 대신 회전을 먼저 구현하겠습니다.

비행기가 움직이면 카메라도 따라 움직여야 하는데,
그 기능은 제대로 추가하기가 조금 시간이 걸려서요.

가만히 있는 상태에서 비행기를 회전시키는 일부터 먼저 하겠습니다.

출처: https://en.wikipedia.org/wiki/Aircraft_principal_axes

다시 비행기의 3축 운동 개념을 가져왔습니다.
Roll, Pitch, Yaw 축을 이용해서 비행기를 조종하죠.

Roll: Z
Pitch: X
Yaw: Y

이걸 유니티 좌표계에 대입시키면 이렇게 구성이 되네요.


public float rollAmount;
public float pitchAmount;
public float yawAmount;

Vector3 rotateValue;
Rigidbody rb;

void MoveAircraft()
{
    rotateValue = new Vector3(pitchValue * pitchAmount, 
                              yawValue * yawAmount, 
                              rollValue * rollAmount);
                              
    rb.MoveRotation(rb.rotation * Quaternion.Euler(rotateValue * Time.fixedDeltaTime));
}

void Start()
{
	rb = GetComponent<Rigidbody>();
}

void FixedUpdate()
{
    MoveAircraft();
}

중요: 빠른 속도로 움직이는 물체의 충돌 처리를 위해서는 Rigidbody의 속도와 회전값을 수정하는 방식으로 사용해야 합니다.
따라서, transform.Rotate(), transform.translate() 대신 rigidbody.MoveRotation()과 rigidbody.velocity = ...를 사용합니다.

각각의 value 값은 이미 #1에서 추가된 상태고,
Roll, Pitch, Yaw 키가 입력되면 '얼마만큼 돌릴지' 결정하는 Amount 값을 추가했습니다.

그리고 MoveAircraft()라는 이동 관련 함수를 만들고, 여기서 회전 처리를 해보겠습니다.
FixedUpdate()에서 실행되니까 Time.fixedDeltaTime은 꼭 곱해줍시다.

그리고 Amount 값을 조정합니다.

(Accel(erate)과 Brake도 나중에 쓸 예정이라 미리 추가했습니다.)

해보니까 Roll 회전 방향이 반대였더군요.
roll 값에 '-' 를 추가해줘야겠습니다.



조금 더 자연스러운 회전

지금은 입력에 따라서 너무 즉각적으로 돌아가는 것 같습니다.
대략 1초 동안은 최대값까지 서서히 도달해야 자연스럽게 느낄 것입니다.

그럴 때 자주 쓰는 게 있죠. Lerp입니다.

public float lerpAmount;

void MoveAircraft()
{
    Vector3 lerpVector = new Vector3(pitchValue * pitchAmount, yawValue * yawAmount, -rollValue * rollAmount);
    rotateValue = Vector3.Lerp(rotateValue, lerpVector, lerpAmount * Time.fixedDeltaTime);

    rb.MoveRotation(rb.rotation * Quaternion.Euler(rotateValue * Time.fixedDeltaTime));
}

Roll, Pitch, Yaw 값을 묶어서 Vector3 값을 만들고,
실제로 회전시키는 데 사용할 값인 rotateValue와 같이 Lerp를 먹여줬습니다.
그리고 어느 속도로 보간을 시킬 지 테스트하기 위해 lerpAmount라는 변수도 추가했고요.

Lerp에도 Time.fixedDeltaTime 곱해줍시다.

방금 전에 테스트한 값을 기반으로 Roll, Pitch, Yaw Amount의 값도 조금 손봐줍니다.

대충 원하는 수준까지 회전 움직임이 구현됐습니다.


비행

이제 비행기를 놓아줍시다.

public float speed;

void MoveAircraft()
{
    // Rotation
    rotateValue = ...         
    rb.MoveRotation(...)

    // Move
    rb.velocity = transform.forward * speed * Time.fixedDeltaTime;
}

speed 변수를 추가하고, transform.forwardTime.fixedDeltaTime을 같이 곱한 후 rb.velocity에 대입합니다.

얼마가 적절한 지 모르겠는데 일단 speed에 10을 주죠.




잘 굴러... 아니 날아가네요.
근데 여기서 더 가기 전에 카메라부터 좀 조정을 해야 할 것 같습니다.

언젠가 쓰게 될 조종사 시점입니다.

에이스 컴뱃 시리즈는 조종사 시점 2개 (콕핏 표시 유무),
그리고 지금까지 봐왔던 3인칭 시점 1개를 합해서 총 3개의 시점으로 전환할 수 있습니다.

지금 만든 건 콕핏이 보이는 조종사 시점이고요.


이 카메라에서는 Field of View를 60에서 70으로 올리고,
Clipping PlanesNear를 0.01로, Far를 5000으로 설정했습니다.

그리고 디버그용 텍스트도 조종사 시점 카메라로 옮겼습니다.

속도를 조정하고 다시 실행해보죠.

(갑자기 FPS가 왜 바닥을 치냐면... 움짤 용량이 미친듯이 커져서 조정을 좀 했기 때문입니다.)

이제 1인칭 시점으로 자유비행을 할 수 있게 되었습니다.


속력 조절하기

가속

비행기는 무한대로 가속/감속을 할 수 없습니다.
비행기 엔진의 출력에는 한계가 있고, 그에 따른 최대 속력이 있죠.

예를 들어 이 게임의 주인공이 다룰 F-15C는...

그렇다고 하네요.


중요한 건 최대 속력인 3,000km/h까지 선형으로 가속하지는 않는다는 것입니다.

아무리 애프터버너를 킨다고 해도, 최대 속력에 가까워질수록 속력의 상승 속도는 점점 느려지겠지요.
최대 출력에 다다르면 더 이상 속력이 올라가지 않을 것입니다.

간단히 말해서 최대 속력에 가까워질수록 가속이 줄어드는 식을 만들어야 합니다.


maxSpeedspeedmaxSpeed=1speedmaxSpeed\frac{maxSpeed - speed}{maxSpeed} = 1 - \frac{speed}{maxSpeed}

(마크다운이 수식도 입력이 되는군요. 오늘 처음 알았습니다.)

간단하게 만들자면 이렇게 쓰면 되겠죠.
speed가 maxSpeed에 가까워질수록 결과값은 0에 수렴하게 됩니다.


public float maxSpeed = 301.7f;	// 최대 속력
public float accelAmount; // 가속 

float speedReciprocal // maxSpeed의 역수
float accelValue; // 컨트롤러로 얻어오는 값

void onEnable()
{
    ...
    speedReciprocal = 1 / maxSpeed;
}

void MoveAircraft()
{
    // Rotation
    ...

    // Move
    float accelEase = (maxSpeed - speed) * speedReciprocal;
    speed += accelValue * accelAmount * accelEase * Time.fixedDeltaTime;
        
    rb.velocity = transform.forward * speed;
}

위 식을 계산해서 accelEase (0 ~ 1)로 두고,

accelValue (가속 버튼을 누르는 정도), accelAmount (가속도), accelEase, Time.fixedDeltaTime을 모두 곱해줍니다.


accelEase를 계산할 때 maxSpeed로 나누지 않고 그 역수(speedReciprocal)를 구해서 따로 저장하냐고요?

나누기는 곱하기보다 100배 정도 느리기 때문에, 연산을 최소화해보는 겁니다.

https://docs.unity3d.com/Manual/MobileOptimizationPracticalScriptingOptimizations.html
- "Minimize expensive math functions" 항목에 관련된 설명이 있습니다.

유니티 공식 문서에서도, 어떤 상수로 계속 나누는 작업은 역수를 구해서 곱하는 방식으로 대신할 수 있다고 합니다.






야, 어차피 렌더링하는데 비하면 나눗셈 연산은 아무것도 아닌데 괜히 가독성 떨어뜨리는 거 아니냐?










...

물론 화면 렌더링하는 거에 비하면 이 정도 연산은 아무것도 아니겠지만,
그래도 부하를 줄이려는 의식은 가져보자고요.

이제 R2를 누르면서 가속이 잘 되는지 확인해봅니다.

기본 속력에서는 빠르게 올라가고,

최대 속력 (3,017km/h)에 근접할수록 속력이 줄어들다가,

거의 근접하면 속력이 거의 상승하지 않습니다.

참고 : 이 글에서는 km/h로 쓰고 있지만, 이동할 때 쓰는 값은 speed * Time.deltaTime이므로 실제로 찍히는 speed 값은 초당 속력(m/s)입니다.
인식을 쉽게 하기 위해서, 지금은 일부러 10을 곱해서 출력중입니다.
실제로는 3,000km/h = 833.3m/s 입니다.

사실 : 지금 전투기 모델링 기준으로는 유니티 거리 단위 1당 2m라서, 3,000km/h를 표현하기 위해서는 416.67 이 찍혀야 합니다.
지금 움짤에서 10 곱하기 전인 301.6이랑 얼마 차이 안 나네


감속

...

float brakeEase = (speed - minSpeed) * speedReciprocal;
speed -= brakeValue * brakeAmount * brakeEase * Time.fixedDeltaTime;

rb.velocity = transform.forward * speed;

비슷한 방식으로 감속(브레이크)도 구현해줍니다.
차이점으로는 아까는 maxSpeed - speed 였지만 지금은 speed - minSpeed 입니다.

minSpeed에 가까워질수록 느리게 감속해야 하기 때문이죠.

높은 속력에서는 빠르게 감속하고,

최저 속력 (200km/h로 설정)에서는 느리게 감속합니다.

나중에 일정 속력 이하에서는 실속(stall)이 일어나게 할 예정입니다.



기본 속력으로 보정

그리고, 아무런 입력이 없을 때는 자동으로 기본 속력을 향해 비행기 스스로 가/감속합니다.
실제로 그런지는 잘 모르겠지만 아무튼 이 게임에는 그런 기능이 있습니다.

public float calibrateAmount;
...

void MoveAircraft()
{
    // Rotation
    ...

    // Move
    ...

    if(accelValue == 0 && brakeValue == 0)
    {
        speed += (defaultSpeed - speed) * speedReciprocal * calibrateAmount * Time.fixedDeltaTime;
    }
    
    rb.velocity = transform.forward * speed;
}

가속/감속 버튼 둘 다 누르지 않을 때의 속력 보정 코드를 추가했습니다.
이번에는 분자가 (defaultSpeed - speed) 입니다.

기본 속력보다 speed가 높으면 음수, 낮으면 양수가 되어 기본 속력이 되도록 보정해줄 것입니다.

Inspector View에서 보정값을 추가하고 실행해봅시다.

이제 가속, 감속 버튼을 누르지 않는 상황(accel, brake 값이 0)일 때 속력이 600을 향해 맞춰지게 됩니다.


기타: 고도에 따른 속력

비행기의 속력은 비행기의 고도(Altitude)와 밀접한 관련이 있습니다.

비행기의 고도가 높아질수록 대기압이 낮아지고 공기가 희박해집니다.
이 사실이 비행기에 미치는 효과를 잠깐 알아보죠.

  • 산소가 줄어든다.

비행기가 추진력을 얻어 앞으로 나아가기 위해서는 엔진이 연료를 소모하면서 신나게 돌아야 합니다.

비행기의 제트 엔진이 연료를 연소하기 위해서는 공기 중의 산소가 필요합니다. 고고도로 갈수록 공기가 희박해지기 때문에 산소를 얻기가 어려워지고, 엔진의 추진력은 감소하게 됩니다.

  • 공기 저항이 감소한다.

고고도로 갈수록 공기가 희박해진다는 뜻은 반대로 말하면 공기 저항이 감소한다는 뜻도 있습니다. 이 말은 고고도에서 속력이 올라갈 수 있다는 뜻이죠.


정리하자면, 고도가 높아질수록 엔진의 추진력이 감소하지만 공기 저항이 줄어들어 속력이 올라갈 여지가 있습니다.

제트 엔진이 추진력을 얻을만큼 공기가 적당히 존재하면서도, 공기 저항을 최대한 줄여서 최적의 추진력을 얻을 수 있는 높이가 있습니다.

대략 고도 10,000m (35,000 피트) 지점입니다.

이 고도는 연료 소모가 최적인 지점이기도 해서, 제트 엔진을 탑재한 민간 항공기도 1만 미터 근처에서 비행합니다.

(장거리 비행일수록 1만 미터에 가깝게 올라가서 비행하며, 단거리 비행은 이정도로 올라가지 않는 경우도 있습니다.)


에이스 컴뱃 시리즈도 고도가 올라갈수록 최고 속력이 증가하는 깨알같은 기능이 있습니다.

그렇다고 해서 무한대로 올라가서 우주까지 뚫을 수는 없고, 일정 고도까지 가면 (에이스 컴뱃 7 기준 13,000미터) 비행기를 회전시켜 자동으로 내리꽂게 만드는 기능도 있죠.


그 깨알같은 기능을 구현해봅시다.
내리꽂는 거 말고, 고도에 따른 최대 속력 조절이요.

실속 구현은 여기서 볼 수 있습니다.


maxSpeedspeedmaxSpeed\frac{maxSpeed - speed}{maxSpeed}

단순히 가속만 구현했을 때 사용했던 식은 위와 같았습니다.


maxSpeed+altitudespeedmaxSpeed\frac{maxSpeed + altitude - speed}{maxSpeed}

여기에다 분수의 분자에 고도 값을 추가합니다. (고도는 속력에 맞게 약간 수정됩니다.)


이 식을 적용했을 때,
maxSpeed가 2500, altitude가 0이면, speed는 2500까지 서서히 올라갈 것입니다.
altitude가 500이라면, speed는 3000까지 서서히 올라갈 것입니다.

float accelEase = (maxSpeed + (transform.position.y * 0.01f) - speed) * speedReciprocal;
speed += accelValue * accelAmount * accelEase * Time.fixedDeltaTime;

정밀한 계산은 나중에 하고, 현재 비행기의 y좌표에 적당한 상수를 곱해서 altitude 값을 주겠습니다.

y = 300 근처일 때, 최대로 가속하면 2,530 근처에서 가속이 멈춥니다.

y = 5,000 근처일 때는 3,000 근처에서 가속이 멈춥니다.

(*기수를 계속 올리거나 내리느라 y값이 정확히 300, 5000이 되지 않았습니다.)


이제 어느정도 기체 조작을 할 수 있는 상태가 되었습니다.

다음에는 카메라를 구현해보려고 합니다.
1인칭 시점을 자유롭게 컨트롤하고, 3인칭 시점도 좀 더 자유롭게 조종해보겠습니다.



이 프로젝트의 작업 결과물은 Github에 업로드되고 있습니다.
https://github.com/lunetis/OperationZERO

1개의 댓글

comment-user-thumbnail
2022년 10월 17일

감사합니다. 덕분에 업무에 도움이 되었습니다

답글 달기