Ace Combat Zero: 유니티로 구현하기 #5 : 기총

Lunetis·2021년 3월 28일
1

Ace Combat Zero

목록 보기
6/27
post-thumbnail




미사일을 만들 때는 신경써야 할 부분이 정말 많았었습니다.

  • 미사일 모델링부터 시작해서...
  • 발사 후 추진하는 기능
  • 유도 기능
  • 폭발 이펙트
  • 목표물 탐색 중지 기능
  • ...

하지만 기총은 다릅니다. 그냥 발사해서 맞는지만 체크하면 됩니다.

총알이 유도를 하거나 속도가 서서히 증가해야 한다거나 하지는 않으니까요.


굳이 따지자면 예상 탄착 지점을 보여주는 기능이 있긴 한데, 이 부분은 UI를 만들 때 생각하도록 합시다.


기총 추가하기

그나저나 F-15C는 어떤 기총이 탑재되어있는지 알아봅시다.

https://www.military.com/equipment/f-15-eagle

https://ko.wikipedia.org/wiki/M61_벌컨

6배럴 개틀링 기관총을 탑재하고 있다고 합니다.
M61A1은 분당 6000발, M61A2는 6600발의 연사속도를 가진다고 하며,
초당으로 계산하면 100 – 110발 정도를 발사할 수 있습니다.


에이스 컴뱃에서는 이 정도로 빠르게 발사하도록 만들지는 않습니다.
빨라봤자 초당 20발 정도로 발사하죠.

심지어 옛날 작품에서는 이것보다 훨씬 느리게 발사합니다. 초당 10발도 안 되는 수준으로요.


근데 총알이 어디서 나가죠?


이 사진으로 미루어보아...

비행기 오른쪽 날개 부분에 달려 있는 것 같군요.

무심코 지나갈 법한 부분도 구현이 된 것으로 보아,
이 모델링 제작자는 정말 섬세한 사람임이 틀림없습니다.

로우 폴리는 일부러 그렇게 만들었겠죠?

여기에 빈 GameObject를 만들어서 총알이 발사될 위치를 지정해주겠습니다.



총알 만들기

https://youtu.be/VDQBZd5qaV0?t=15773

(움짤 프레임이 낮아서 총알이 이상하게 보일 수 있습니다.)

에이스 컴뱃의 총알은 날아가는 궤적이 보이는 예광탄입니다.

이전에 만들었던 미사일에 비하면 총알은 너무나도 작아서 보이지 않기 때문에,
궤적이라도 보여줘서 플레이어에게 총을 쏘고 있다는 느낌을 주게 해야 합니다.


그러면 총알을 먼저 만들어보죠.

어차피 안 보일테니까 총알은 그냥 하얀색 캡슐로 합시다.


예광탄 효과를 만들기 위해서, 미사일 궤적을 표시할 때 썼던 Trail Renderer를 사용하겠습니다.

적당하게 Width를 조절하고, Time을 0.01로 줘서 매우 짧은 길이로 보이게끔 합니다.

Materials 에 있는 FireFlyTrail은 Effect Examples 에 있습니다.
미사일 만들기에서 사용한 이펙트 패키지에 들어있습니다.


그리고 총알에 적용할 스크립트도 작성해야죠.


Bullet.cs

public class Bullet : MonoBehaviour
{
    Transform parent;
    TrailRenderer trailRenderer;

    public float speed;
    public float lifetime;

    public void Fire(float launchSpeed, int layer)
    {
        speed += launchSpeed;
        gameObject.layer = layer;
    }

    void OnTriggerEnter(Collider other)
    {
        CancelInvoke("DisableBullet");
        DisableBullet();
    }

    void DisableBullet()
    {
        gameObject.SetActive(false);
        transform.parent = parent;
    }

    void OnEnable()
    {
        trailRenderer.Clear();
        Invoke("DisableBullet", lifetime);
    }

    void Awake()
    {
        trailRenderer = GetComponent<TrailRenderer>();
        parent = transform.parent;
    }

    // Update is called once per frame
    void Update()
    {
        transform.Translate(new Vector3(0, 0, speed * Time.deltaTime));
    }
}

미사일에서 사용했던 코드를 거의 그대로 가지고 왔습니다.
유도할 대상 없이 그냥 직진하고, 속력도 고정된다는 차이점이 있습니다.

속력은 총알의 발사 속력 + 비행기의 속력으로 계산됩니다.


주목할 부분이 있다면 OnEnable()에서 trailRenderer.Clear()를 호출하는 부분이 있는데,

이전에 미사일 구현하기에서 오브젝트 풀에 대해서 이야기했었죠.
그 기능을 구현해서 미사일과 이펙트류에 적용해 놓았습니다.

그래서 EnableDisable이 번갈아가면서 호출되는 상태인데,

총알이나 미사일이 재사용되는 과정에서 이전에 Disable되었던 위치에서 현재 위치로 순간이동되는 순간,
TrailRenderer가 그 순간이동의 잔상까지 표시해버리는 상황이 발생합니다.

파란색 화살표 : 공에 맞아서 Disable되었던 위치
초록색 화살표 : 미사일이 발사되어서 Enable된 위치 (미사일을 바로 재사용함)


이런 상황을 막기 위해서 새로 발사할 때마다 TrailRenderer의 잔상을 모두 초기화시키는 작업을 진행해야 합니다.

이제 프리팹에 Bullet 컴포넌트를 적용시키고 발사 속도와 활성화되는 시간을 설정해줍니다.



총 발사하기

이제 M61 발칸포를 구현할 시간입니다.

분당 6600발, 초당 110발까지 발사할 수 있다지만 너무 사기적이므로 너프를 먹이겠습니다.
(사실 성능이나 프레임 문제가 있기 때문에 안 하는겁니다.)


WeaponController.cs


ObjectPool bulletPool;

public int bulletCnt;
public Transform gunTransform;
public float gunRPM;
float fireInterval;

public void GunFire(InputAction.CallbackContext context)
{
    switch(context.action.phase)
    {
        case InputActionPhase.Performed:
            isGunFiring = true;
            InvokeRepeating("FireMachineGun", 0, fireInterval);
            break;

        case InputActionPhase.Canceled:
            isGunFiring = false;
            CancelInvoke("FireMachineGun");
            break;
    }
}


void FireMachineGun()
{
    if(bulletCnt <= 0)
    {
        // Beep sound
        CancelInvoke("FireMachineGun");
        return;
    }

    GameObject bullet = bulletPool.GetPooledObject();
    bullet.transform.position = gunTransform.position;
    bullet.transform.rotation = transform.rotation;
    bullet.SetActive(true);

    Bullet bulletScript = bullet.GetComponent<Bullet>();
    bulletScript.Fire(aircraftController.Speed, gameObject.layer);
    bulletCnt--;
}

void Start()
{
    ...
    
    bulletPool = GameManager.Instance.bulletObjectPool;
    fireRate = 60.0f / gunRPM;
}

public 변수 gunRPM으로 분당 발사수를 정하면,
Start()에서 발사 간격인 fireInterval를 계산합니다.

기총 발사 키가 눌리면 InvokeRepeating(...)에서 총알을 반복적으로 발사하도록 합니다.
발사 키에서 손을 떼면 CancelInvoke(...)를 실행하여 총알 발사를 멈춥니다.

이제 추가된 변수를 Inspector View에서 설정하고 실행합니다.

  • bulletCnt : 총알 개수
  • gunTransform : 발사 위치
  • gunRPM : 분당 발사 수 (현재 1200, 초당 20발로 설정)


총알 발사와 궤적 표시가 잘 되는군요.


탄착 이펙트

총알이 어딘가에 맞았으면 뭔가 이펙트가 있어야 눈이 즐거워지죠.

제대로 만든다면 땅에 맞을 때는 먼지가, 비행기에 맞을 때는 스파크가 튀겠지만,
일단은 그냥 스파크로 통일하겠습니다.

이전에 사용했던 이펙트 패키지 중 ElectricSparks라는 이펙트를 이용해보겠습니다.

  • Loop 제거, 일회성 이벤트
  • 0.2초동안만 파티클 생성
  • 파티클 생성 개수 증가
  • 파티클이 퍼지는 방향 조정
  • 월드와 충돌 체크

그리고 총알 스크립트에서도 맞으면 이펙트를 생성하도록 기능을 추가합니다.

Bullet.cs

void OnTriggerEnter(Collider other)
{
    CreateHitEffect();
    DisableBullet();
}

void CreateHitEffect()
{
    // Instantiate in world space
    ObjectPool effectPool = GameManager.Instance.bulletHitEffectObjectPool;
    GameObject effect = effectPool.GetPooledObject();
    effect.transform.position = transform.position;
    effect.transform.rotation = transform.rotation;
    effect.SetActive(true);
}

OnTriggerEnter()는 무언가에 맞았을 때 실행됩니다.
CreateHitEffect()는 파티클을 오브젝트 풀에서 꺼내서 맞은 위치에 활성화시키는 역할을 합니다.


이제 다시 비행기를 출격시켜서 테스트해봅시다.



공에 맞을 때마다 스파크가 튀는 모습을 볼 수 있습니다.



번외 : 컨트롤러 진동

보통 컨트롤러로 무언가를 쏘는 게임을 할 때는 쏠 때마다 컨트롤러에 진동이 옵니다.

여기도 기총을 쏠 때마다 컨트롤러가 진동하도록 기능을 추가해봅시다.

WeaponController.cs

Gamepad gamepad;
public float vibrateAmount;

public void GunFire(InputAction.CallbackContext context)
{
    switch(context.action.phase)
    {
        case InputActionPhase.Performed:
            isGunFiring = true;
            InvokeRepeating("FireMachineGun", 0, fireInterval);

            // Vibration
            Vibrate(vibrateAmount);
            break;

        case InputActionPhase.Canceled:
            isGunFiring = false;
            CancelInvoke("FireMachineGun");

            Vibrate(0);
            break;
    }
}

void Vibrate(float vibrateAmount)
{
    // Vibration
    if(gamepad != null)
    {
        gamepad.SetMotorSpeeds(vibrateAmount, vibrateAmount);
    }
}

// Start is called before the first frame update
void Start()
{
    gamepad = Gamepad.current;
    ...
}

Gamepad 변수를 하나 추가하고, Start()에서 현재 연결된 게임패드를 얻어옵니다.
gamepad.SetMotorSpeed(...) 함수를 사용해서 컨트롤러가 진동하도록 만듭니다.

함수에 넘겨주는 변수 2개는 각각 왼쪽, 오른쪽 모터의 진동 세기입니다.

총을 쏘는 Input 함수에서는 Performed 시에 진동을 시작, Canceled 시에 진동을 멈추도록 만들어줍니다.


이제 실행하면 총을 쏠 때마다 컨트롤러에서 진동을 느낄 수 있습니다.


충돌 감지 방식 변경

현재 충돌 감지 방식에 문제점이 있습니다.
빠르게 지나가는 물체에 대해서 충돌을 감지하지 못할 수 있다는 부분입니다.

땅에 대고 총알을 발사할 때, 총알 이펙트가 거의 발생하지 않고 있습니다.

분명히 총알이 충돌했을 때의 이펙트를 추가했고, 구체에 대고 발사했을 때는 이펙트가 잘 나왔습니다.
근데 왜 땅에 대고 발사하면 이펙트가 안 나올까요?


지면에 대한 충돌체인 Terrain Collider두께가 매우 얇은 면이며,
얇은 면에 닿아야 충돌 판정이 발생합니다.

반면 구체 충돌체인 Sphere Collider구 안에 포함되면 충돌 판정이 발생합니다.


그리고 지금 총알과 미사일은 transform.Translate()를 이용해서 움직이고 있습니다.

이 방식은 매 프레임마다 객체를 순간이동시키는 방식으로 작동합니다.
프레임 사이에 있을수도 있는 위치를 전혀 계산하지 않는다는 뜻입니다.

Terrain Collider는 얇은 면이라고 했었죠.

만약 특정 프레임에 총알이 면과 닿는 상황이 발생하면, 충돌을 감지할 것입니다.

하지만 닿는 프레임 없이 지나가버린다면, 충돌을 감지하지 못합니다.

미사일은 그나마 충돌체의 크기라도 커서 이 문제가 덜 발생했지만,
총알은 속도가 빠르면서도 충돌체의 크기가 작아서 이렇게 충돌을 감지하지 못하는 문제가 굉장히 많이 발생하고 있습니다.

그래서 이펙트가 나오지 않고 있죠.




이 문제를 해결하기 위해, 미사일과 총알에 대해서는 rigidbody를 이용한 이동으로 교체하려고 합니다.

1. 충돌 감지 방식

먼저 Rigidbody의 Collision Detection을 Continuous로 바꿔줍니다.

기본값인 Discrete는 Fixed Delta Time (기본값 0.01초) 마다 위치를 체크해서 충돌을 감지하는 방식입니다. 불연속 충돌 검사라고도 합니다.
Update보다는 낫지만, 여전히 빠르게 이동하는 물체를 감지하지 못할 수 있습니다.

지형에 대해서라면, Continuous 또는 Continuous Dynamic을 선택해줍시다.

Continuous를 선택했을 경우,

  • Rigidbody를 가지고 있는 동적인 물체에 대해서는 Discrete처럼 검사하지만,
  • 지형처럼 Rigidbody를 가지고 있지 않은 정적인 물체에 대해서는 연속 충돌 검사를 사용합니다.

Continuous Dynamic으로 설정된 동적 물체끼리Continuous와 같이 연속 충돌 검사를 사용합니다.


나중에 날아다니는 비행기를 맞출 때를 생각해봅시다.
총알이나 비행기나 굉장히 빠른 속도로 날아다니는데, 그 상황에서 Discrete 방식이 사용된다면 아마 총알을 맞추기는 불가능에 가까울지도 모릅니다.

따라서 종합적으로 생각해보면 Continuous Dynamic을 설정해주는 것이 맞겠네요.

ContinuousDiscrete보다 비용이 큽니다. 꼭 필요한 부분에서만 사용하도록 합시다.

Discrete를 사용했을 때는 그냥 뚫고 들어갑니다.

Continuous Dynamic을 사용했을 때는 총알이 제대로 지형과의 충돌을 감지하고 이펙트를 생성하는 것을 확인할 수 있습니다.

(눈에 맞았을 때 이펙트를 만들었습니다.)




그리고 Interpolate 속성을 None에서 Interpolate로 바꿔줬는데,
이게 어떤 곳에 쓰이냐면,

https://docs.unity3d.com/kr/current/Manual/class-Rigidbody.html

None으로 설정하면 카메라에 보이는 총알이나 미사일이 떨리는 것처럼 보이게 됩니다.
날아가는 객체가 부드럽게 보이게 하기 위해서 사용합니다.



2. 총알 스크립트 변경

총알은 그냥 직선으로 나가니까, 복잡한 계산식은 사용하지 않겠습니다.
그냥 시작할 때 rigidbody의 속도를 세팅하고, 더 이상 건들지 말죠.

Bullet.cs

public class Bullet : MonoBehaviour
{
    Rigidbody rb;
    public float speed;

    public void Fire(float launchSpeed, int layer)
    {
        speed += launchSpeed;
        gameObject.layer = layer;
        rb.velocity = transform.forward * speed;
    }

    void OnCollisionEnter(Collision other)
    {
        ...
    }

    void Awake()
    {
        rb = GetComponent<Rigidbody>();
        ...
    }
    
    void OnDisable()
    {
        rb.velocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;
    }
}

주요 변경점은,

  • Rigidbody 변수를 가지고 있도록 만들고, Awake()에서 얻어옵니다.
  • OnTriggerEnter(Collider other)OnCollisionEnter(Collision other)로 변경합니다.
  • 그리고 Disable될 때 rigidbody의 속도와 각속도를 모두 0으로 만들어줍니다.

각속도를 왜 0으로 설정하냐면, 충돌했을 때 총알이 튕겨나가면서 각속도가 바뀌기 때문입니다.

초기화하지 않은 상태에서 오브젝트 풀에 그냥 넣고 다시 활성화시키면 총알이 이상한 방향으로 나가게 됩니다.



3. 미사일

미사일은 약간 까다롭습니다. 유도되면서 방향과 속도가 바뀌기 때문이죠.

public class Missile : MonoBehaviour
{
    Rigidbody rb;

    void LookAtTarget()
    {
        ...
        Quaternion lookRotation = Quaternion.LookRotation(targetDir);
        rb.rotation = Quaternion.Slerp(rb.rotation, lookRotation, turningForce * Time.fixedDeltaTime);
    }

    void OnCollisionEnter(Collision other)
    {
        ...
    }

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

    private void OnDisable()
    {
        rb.velocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;
    }

    void FixedUpdate()
    {
        LookAtTarget();
        if(speed < maxSpeed)
        {
            speed += accelAmount * Time.fixedDeltaTime;
        }

        rb.velocity = transform.forward * speed;
    }
}

총알에는 FixedUpdate()가 없었지만 미사일은 필요합니다.
날아가면서 지속적으로 속도와 방향을 바꿔줘야 하기 때문이죠.

  • 총알과 마찬가지로 Rigidbody 변수, OnCollisionEnter(Collision other)을 사용합니다.
  • Disable될 때 속도와 각속도를 0으로 설정합니다.
  • FixedUpdate()에서는 LookAtTarget()을 호출해서 rb.rotation에 접근하여 방향을 조절하고, rb.velocity에 접근하여 속도를 변경시킵니다.

이제 이펙트가 잘 나오는지 테스트하러 갑시다.


공에 대고 쐈을 때 제대로 폭발과 탄착 이펙트가 나오고 있습니다.
뭔가 잠시 그래픽이 깨진 것 같은데, 이건 나중에 알아보죠.

이제 땅에 대고 미사일과 총알을 발사할 때도 정상적으로 이펙트가 출력됩니다.


이번 시간의 교훈 : 빠르게 이동하는 물체는 rigidbody로 충돌체크합시다.



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

0개의 댓글