Ace Combat Zero: 유니티로 구현하기 #23 : 컷씬 (2)

Lunetis·2021년 8월 3일
0

Ace Combat Zero

목록 보기
24/27
post-thumbnail

TMI: 이 음악은 2020 도쿄 올림픽 개막식에서 선수단이 입장하는 동안 사용되었습니다.
작곡가 코바야시 케이키는 개막식에서 직접 듣기 전까지 자신의 음악이 개막식에 사용되었다는 사실을 몰랐다고 합니다.



컷씬 제작

이전 포스트에 이어서 계속 컷씬을 만들어봅시다.

카메라 구도를 잡고 시점을 고정하는 기본적인 것들은 이전 포스트에서 설명했으니,
몇 가지 테크닉만 설명해보려고 합니다.


카메라 시점 돌리기

먼 산을 바라보다가 갑자기 픽시(적 비행기)가 있는 쪽으로 시점을 돌립니다.

사용된 카메라는 한 개로 보이지만, 시네머신에서는 두 개의 카메라를 사용해서 만들 수 있습니다.
왜 카메라 개수를 늘려서 일을 더 복잡하게 만드냐는 의문이 들 수 있지만, 생각보다 간단합니다.

먼 산을 바라보는 카메라 하나, (vcam4)

비행기를 바라보는 카메라 하나를 준비합니다. (vcam5)

그리고 시네머신 트랙에서 두 카메라의 클립을 서로 겹치게 놓으면,

카메라의 시점이 즉시 바뀌지 않고, vcam4의 시점에서 vcam5의 시점으로 돌아갑니다.

시점이 전환되는 속도는 Blend Curve로 조절할 수 있습니다.



연출 조작하기

이 장면을 보면서 생각한건데,

미사일이 굉장히 높이 올라갔거든요?

그리고 다음 컷에서 픽시를 비추는데, 픽시의 고도가 엄청 높아보이진 않습니다.
아직 주변에 산맥이 보이는데...

뒤로 미사일이 지나갑니다.


미사일의 위치가 조작된 게 아닌가 하는 의문점이 생겼죠.

AWACS가 말한 후에 픽시가 말했다고 보기에는 미사일의 고도가 너무 낮습니다.
AWACS가 미사일이 발사됐다는 말을 하는 것과 동시에 픽시가 말하는 것이었다면 이해가 갑니다만...


이렇게 픽시를 강조할 때 미사일이 픽시와 비슷한 고도에 위치해있어야 합니다.
올라갔던 미사일을 다시 내려보내야 할 것 같네요.

미사일을 다시 아래로 배치시키고 서서히 위로 올리는 애니메이션을 만든 후,
화면을 전환할 때 그 애니메이션을 바로 재생시켜서 다시 미사일을 아래로 배치시킵니다.

그리고 미사일이 카메라에 잡히도록 고도 및 실행 타이밍을 조절합니다.

그러면 아까 끝까지 올라갔던 미사일이 다시 내려와서 카메라에 잡히게 됩니다.




과연 컷씬을 만들면서 얼마나 많은 조작질을 하게 될지 두고 봅시다.

저는 원본을 충실히 재현하기 위해 노력할 뿐입니다.



타임라인 실행 중에 타겟 UI 표시하기

평소에 띄워줬던 타겟 UI가 컷씬에서도 표시됩니다.

GameObject를 지정해주면 알아서 그 위치에 UI를 띄워주는 기능은 만들어놓았는데,
컷씬에서는 작동하지 않길래 코드를 보니 수정해야 할 부분이 있었습니다.

대상을 보는 카메라에 따라서 타겟 UI의 위치는 다를 수 있습니다.
컷씬 연출 중에는 컷씬에 사용되는 카메라 위에 UI가 그려져야 하는데, 계속 플레이어의 3인칭 또는 1인칭 카메라만 가져와서 UI를 표시하고 있었습니다.

FollowTransformUI.cs

[SerializeField]
protected Camera cam;
[SerializeField]
protected bool trackCurrentCamera = true;


// Update is called once per frame
protected virtual void Update()
{
    if(targetTransform == null) return;
    
    if(trackCurrentCamera == true)
    {
        cam = GameManager.CameraController.GetActiveCamera();
    }
    
    ...
}

bool trackCurrentCamera라는 속성을 추가해서, 이 속성이 true면 플레이어가 조종하는 카메라 위에 UI가 그려지도록 만들었습니다.
false일 때는 직접 카메라를 지정해줘야 합니다.

컷씬에 사용되는 타겟 UI는 이 속성을 false로 두고 카메라를 컷씬 카메라로 지정합니다.

이제 저도 컷씬에 UI가 표시됩니다.

아무리 봐도 이 비행기가 ADFX-02가 아닌 F-15인 것이 너무 한스럽네요.
나중에 제가 모델링을 해서라도 추가하고 싶습니다.


카메라 구도를 모두 잡고, 약간의 오디오를 추가한 상태의 타임라인 트랙의 상태입니다.



자막을 위한 커스텀 Playable 트랙 만들기

이제 컷씬에 자막 출력 기능을 추가해야 합니다.
선택한 언어에 따른 문장 출력 분기 기능도 필요하죠.

타임라인에서 오디오를 원하는 타이밍에 출력하는 기능은 있지만, 내가 원하는 자막을 출력하는 기능은 기본적으로는 제공되지 않습니다.

에셋 스토어에 들어가면 Default Playables라는, 유니티에서 타임라인 기능을 확장하여 만들어놓은 몇 가지 Playable 기능들을 사용할 수 있습니다.

이렇게 예제 Playable들을 제공해주는데, 그 중에 TextSwitcher라는 Playable이 있습니다.

어떤 기능인지 궁금해서 다운로드해봤는데, TextMeshPro가 아닌 Unity UI 기본 Text를 하드코딩 방식으로 바꿔주는 Playable이었습니다.
물론 이 패키지의 코드를 약간만 고친다면 TextMeshPro의 텍스트를 바꿔줄 수 있겠지만, 그것으로 충분하지는 않습니다.

왜냐하면 나중을 대비해서 언어 설정에 따른 분기 처리가 필요하고, 이를 위해 자막 기능을 구현할 때 자막 텍스트를 넘겨주지 않고 자막의 키값(A2_1, P2_S3 등)을 넘겨주는 방식으로 구현했습니다.

자막을 처리하는 Playable을 만든다고 하면, 여기서도 자막을 하드코딩 방식으로 넘겨주는 게 아니라 키값을 넘겨주는 방식으로 구현해야 합니다.



필요한 기능 생각하기

트랙의 구조를 먼저 생각해봅시다.
이 Playable 트랙은 바뀌어야 하는 텍스트 UI 오브젝트를 직접 지정해주고 있습니다.

이 방법은 그대로 가져가도 될 것 같습니다. 대신 Text가 아닌 TextMeshPro를 사용하도록 바꾸죠.


각 트랙의 길이만큼 텍스트를 보여주고, 트랙이 없는 공백 구간에는 텍스트 오브젝트를 숨김 처리하거나 텍스트를 공백으로 지정하면 될 것 같습니다.


Blend Curves까지는 어느 트랙이나 공통으로 가지고 있는 속성이니, 그 아래에 있는 Text Switcher Clip 부분을 다 들어내고 무슨 파라미터를 넘겨줘야 할 지 생각해봅시다.

위에서도 언급했듯이 자막 텍스트가 아닌 자막 키값을 넘겨주는 방식이 되어야 하기 때문에, 키값을 담을 string 변수 하나만 있으면 될 것 같습니다.



트랙 만들기

먼저 트랙부터 만들어봅시다. 트랙에는 TextMeshPro 오브젝트를 등록할 수 있어야 합니다.


SubtitleTrack.cs

using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;
using System.Collections.Generic;
using TMPro;

[TrackColor(0.2f, 0.4f, 0.6f)]
// [TrackClipType(typeof(SubtitleClip))]
[TrackBindingType(typeof(TextMeshProUGUI))]
public class SubtitleTrack : TrackAsset
{

}

클래스 안에 아무것도 없습니다. 그냥 TrackAsset을 상속받을 뿐입니다.
그 위에는 트랙의 색을 지정해주는 TrackColor, 트랙에 바인딩할 오브젝트의 타입을 지정한느 TrackBindingType이 있습니다.

TextMeshProUGUI 오브젝트를 등록해야 하므로 typeof(TextMeshProUGUI)를 적어넣었습니다.


특정 클립만 생성 가능하도록 만들어주는 TrackClipType이 있는데, 아직 클립 코드를 만들지 않았으므로 이름만 적어놓고 일단 주석으로 처리합니다.


이제 타임라인 창에서 우클릭을 하면 Subtitle Track이라는 항목이 생깁니다.

생성하면 이렇게 TextMeshProUGUI 오브젝트를 등록할 수 있습니다.

미리 만들어놓은 텍스트 오브젝트를 등록합니다.



클립 만들기

이제 트랙을 채워놓을 클립을 만들어야 합니다.

각 클립마다 자막의 키값을 지정할 수 있어야 하며, 클립의 시작 부분에는 자막 텍스트를 키값에 맞게 바꿔줘야 합니다.

그리고 클립의 끝 부분에는 자막 텍스트를 숨겨야 하죠.


SubtitleBehaviour.cs

using System;
using UnityEngine.Playables;

[Serializable]
public class SubtitleBehaviour : PlayableBehaviour
{
    public string subtitleKey;
}

클립의 파라미터를 가지는 Behaviour 클래스를 하나 만들어줍니다.

키값을 가지는 string 변수 하나만 있으면 될 것 같습니다.


SubtitleClip.cs

using System;
using UnityEngine;
using UnityEngine.Playables;
using UnityEngine.Timeline;

[Serializable]
public class SubtitleClip : PlayableAsset, ITimelineClipAsset
{
    public SubtitleBehaviour template = new SubtitleBehaviour ();

    public ClipCaps clipCaps
    {
        get { return ClipCaps.None; }
    }

    public override Playable CreatePlayable (PlayableGraph graph, GameObject owner)
    {
        var playable = ScriptPlayable<SubtitleBehaviour>.Create (graph, template);
        return playable;
    }
}

자막 클립에 대한 코드입니다.

ClipCaps는 클립이 어떤 속성을 추가로 가질 수 있는지 설정하는 enum 입니다.
루프를 가능하게 하는 Looping, 서로 겹쳐서 블렌딩을 할 수 있는 Blending 등의 속성이 있지만,
하나도 필요 없으니 None으로 설정하겠습니다.

그리고 CreatePlayable() 함수를 추가합니다.

클립 코드가 만들어졌으니, 트랙 코드에서 주석 처리했던 TrackClipType 부분을 살려놓습니다.


이제 트랙에 대고 우클릭을 하면 클립을 생성할 수 있습니다.

클립을 클릭하면 아까 멤버 변수로 선언했던 Subtitle Key 가 나옵니다.






와, 코드도 되게 짧고 별로 복잡한 것도 없네요?
트랙이랑 클립 코드, 클립에 담길 데이터 코드까지 다 짰으니 이제 그냥 쓰면 되나요?



생각해보세요. 키값을 자막으로 변환시키는 코드는 하나도 작성하지 않았습니다.




그 짧은 코드로 끝날 리가 없죠. 이제부터 시작입니다.



클립 기능 구현하기: MixerBehaviour

각 클립에 대한 기능을 구현하는 클래스를 따로 만들어줘야 합니다.

우선 트랙 코드로 다시 돌아가서, 비어있는 클래스 내부를 조금 채워줘야 합니다.


SubtitleTrack.cs

public class SubtitleTrack : TrackAsset
{
    public override Playable CreateTrackMixer(PlayableGraph graph, GameObject go, int inputCount)
    {
        return ScriptPlayable<SubtitleMixerBehaviour>.Create (graph, inputCount);
    }
}

public override Playable CreateTrackMixer(...) 함수를 추가해줍시다.
트랙에 있는 클립 데이터를 SubtitleMixerBehaviour에서 분석하여 클립을 처리할 수 있게 됩니다.



SubtitleMixerBehaviour.cs

public class SubtitleMixerBehaviour : PlayableBehaviour
{
    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        int inputCount = playable.GetInputCount();
        int currentInputCount = 0;

        for (int i = 0; i < inputCount; i++)
        {
            float inputWeight = playable.GetInputWeight(i);
            if(inputWeight == 1) currentInputCount++;
        }

        Debug.Log("ProcessFrame : " + currentInputCount);
    }
    
    public override void OnPlayableDestroy (Playable playable)
    {
        Debug.Log("OnPlayableDestroy");
    }
}

일단 코드가 돌아가는 방식부터 살펴보겠습니다.


ProcessFrame()은 게임을 실행 중인 Play 모드 뿐만 아니라 Edit 모드에서도 호출됩니다.
타임라인의 커서를 마우스로 잡고 왔다갔다 해도 호출된다는 뜻입니다.

playable.GetInputWeight(i)는 현재 커서 위치에서 i번째 클립이 얼마나 큰 Weight를 차지하는지 알려주는 함수입니다.
클립이 하나만 있을 때 Weight 값은 1이지만, 겹쳐진 상태 (블렌딩)에서는 2개의 클립이 0 ~ 1 사이의 값을 가질 수 있습니다. (두 클립의 Weight를 합치면 1입니다.)
하지만 이번에 만드는 클립은 블렌딩을 허용하지 않기 때문에, 1로만 고정이 될 것입니다.

OnPlayableDestroy()는 클립이 삭제될 때 호출됩니다.

하단의 디버그 로그를 보면,

트랙에 클립을 놓고 커서를 움직이면 "ProcessFrame : (현재 커서의 클립 개수)"가 출력되고,
클립을 삭제할 때 "OnPlayableDestroy"라는 문구가 출력됩니다.

이제 디버그용 코드는 삭제하고, 구현하려던 기능을 작성합시다.



SubtitleMixerBehaviour.cs

public class SubtitleMixerBehaviour : PlayableBehaviour
{
    string subtitleKey;

    TextMeshProUGUI textMeshPro;

    public override void ProcessFrame(Playable playable, FrameData info, object playerData)
    {
        textMeshPro = playerData as TextMeshProUGUI;
        if(textMeshPro == null) return;

        int inputCount = playable.GetInputCount();
        bool isOnClip = false;

        for (int i = 0; i < inputCount; i++)
        {
            float inputWeight = playable.GetInputWeight(i);
            if(inputWeight == 1)
            {
                isOnClip = true;
                
                ScriptPlayable<SubtitleBehaviour> subtitlePlayable = (ScriptPlayable<SubtitleBehaviour>)playable.GetInput(i);
                SubtitleBehaviour subtitleBehaviour = subtitlePlayable.GetBehaviour();

                // Clip change detected
                if(subtitleKey != subtitleBehaviour.subtitleKey)
                {
                    subtitleKey = subtitleBehaviour.subtitleKey;

                    if (Application.isPlaying)
                    {
                        textMeshPro.text = GameManager.ScriptManager.GetSubtitleText(subtitleKey);
                    }
                    else if (Application.isEditor)
                    {
                        textMeshPro.text = subtitleKey;
                    }
                }
            }
        }
        
        // Passing empty section
        if (isOnClip == false)
        {
            textMeshPro.text = "";
            subtitleKey = "";
        }
    }

    public override void OnPlayableDestroy (Playable playable)
    {
        if(textMeshPro == null) return;
        textMeshPro.text = "";
    }
}

클립이 바뀔 때 (현재 subtitleKey와 커서가 가리키는 클립의 subtitleKey가 다를 때), 그리고 타임라인에서 빈 공간을 지나갈 때만 텍스트 내용을 바꿔주도록 함수를 구현했습니다.

트랙에 등록되어있는 TextMeshProUGUIplayerData as TextMeshProUGUI로 얻어오고,
클립의 subtitleKey를 이용해서 ScriptManager.GetSubtitleText() 함수를 통해 키값에 맞는 자막 데이터를 얻어옵니다.

(GetSubtitleText()를 private에서 public으로 바꿔줬습니다.)

내부에 Application.isPlayingApplication.isEditor로 구분지어놨는데, 게임 실행 중이 아닌 편집 상태에서 커서를 옮길 때는 GameManager.ScriptManager에 접근할 수 없으므로 subtitleKey를 그대로 보여주고, 게임 실행 중일 때만 ScriptManager에 접근해서 텍스트를 바꿔주도록 만들었습니다.

비어있는 공간에 들어가거나 클립를 삭제할 때는 텍스트를 공백으로 초기화합니다.

이제 클립들을 생성해서 배치해줍시다.

SubtitleClip 코드에서 ClipCaps를 None으로 지정했기 때문에, 클립이 겹쳐지지 않습니다.

모든 클립 데이터가 추가된 모습입니다.

바로 위에 있는 오디오 트랙과는 달리, 어떤 자막 클립들은 공백 없이 두 개가 연속적으로 붙어있는 것도 있습니다.
이 때는 자막이 전환될 때 중간에 공백 없이 바로 다음 클립의 자막 데이터로 바뀌게 됩니다.

그리고 자체적으로 구현했다는 것을 보여주기 위해 실제 컷씬과는 자막에 약간의 차이가 있습니다.

커서를 훑으면서 에디터 상에 자막이 키값으로 제대로 표시되는지 확인해봅니다.

데이터는 잘 들어간 것 같으니, 이제 게임을 실행해서 확인해보죠.








대사가 잘 출력되네요.



게임에 연결하기

지금 만든 컷씬은 페이즈 2의 픽시의 체력을 모두 깎아놓고, "It's time." 대사가 출력된 후에 실행되어야 합니다.

이전 포스트에서 MissionZERO.csPlayPhase3Cutscene() 함수를 구현했는데, 이 함수를 대사가 끝나면 호출해줘야 합니다.

해당 대사의 키값은 "P3_0"이고, "invokeMethodName" 값은 "OnPhaseStart"였습니다.
이 값을 "PlayPhase3Cutscene" 으로 바꿔놓습니다.


대사 출력 후에 컷씬 진입을 확인하고,

컷씬이 끝나면 페이즈 3로 진입하는 것도 확인합니다.



스킵 기능

컷씬을 처음 볼 때는 재밌을 지 몰라도, 두 번째부터는 슬슬 건너뛰고 싶은 욕구가 증가합니다.
이미 다 알고 있는 내용이니까요.

게임을 빨리 클리어하려는 사람들을 위한 스킵 기능을 추가합시다.


컷씬 스킵 키는 일반적으로 게임을 일시정지하거나 옵션으로 들어가는 키에 바인딩됩니다.

스킵을 하면 컷씬이 페이드 아웃되면서 컷씬에서 나오는 소리도 같이 줄어듭니다.
그리고 바로 페이즈 3으로 진입해야 하죠.


한 가지 유의해야 할 점이 있는데, 컷씬이 재생되는 도중에는 스킵을 위한 키를 제외한 모든 키들이 비활성화되어야 합니다.
컷씬 재생 중 미사일을 발사한다거나, 옵션에 들어간다거나 하는 일이 있어서는 안 됩니다.



컷씬용 Input Action Map 추가

이전에 일시정지 화면을 만들 때 입력 시스템에서 액션 맵을 다뤘었는데, 여기서도 다뤄야겠네요.

좌측에 Action Map을 "Cutscene"이란 이름으로 추가하고, Actions에는 "Skip"이란 액션을 추가합니다. 이 액션은 Player 액션 맵의 Pause 액션과 같습니다.

이제 코드를 통해 "Cutscene" 액션 맵을 활성화하면, 게임패드의 Start에 해당하는 버튼 (듀얼쇼크의 Options 키 등), 키보드의 ESC 버튼에 해당하는 액션 이벤트만 실행됩니다.



스킵 기능 구현

CutsceneController.cs

[Header("Skip Properties")]
[SerializeField]
GameObject skipUI;
[SerializeField]
FadeController fadeController;

[SerializeField]
float skipCheckTime = 3.0f;

// ======= Input =======

public void OnSkip(InputAction.CallbackContext context)
{
    if(context.action.phase == InputActionPhase.Started)
    {
        // First Press
        if(skipUI.activeInHierarchy == false)
        {
            skipUI.SetActive(true);
            Invoke("HideSkipUI", skipCheckTime);
        }
        // Second Press
        else
        {
            Skip();
            CancelInvoke("HideSkipUI");
        }
    }
}

void HideSkipUI()
{
    skipUI.SetActive(false);
}

void Skip()
{
    fadeController.OnFadeOutComplete.AddListener(playableDirector.Stop);
    fadeController.FadeOut(FadeController.FadeInReserveType.InstantFadeIn
}


void OnCutsceneStart()
{
    playerInput.SwitchCurrentActionMap("Cutscene");
    ...
}


void OnPhase3CutsceneEnded(PlayableDirector director)
{
    ...
    playerInput.SwitchCurrentActionMap("Player");
}

컷씬 컨트롤러에 스킵 관련 기능을 추가합니다.

스킵 버튼을 한 번 누르면 스킵 UI를 띄워주고, UI가 띄워진 상태에서 한 번 더 누르면 진짜로 컷씬을 스킵하고 예정된 페이즈로 넘어갑니다.

컷씬을 스킵할 때는 FadeController에 페이드 아웃을 요청하고, 페이드 아웃이 끝날 때 호출할 함수로 playableDirector.Stop()을 추가합니다.

현재 실행되고 있는 컷씬의 재생이 멈출 때 OnPhase3CutsceneEnded()를 호출하도록 코드를 작성해놨기 때문에, 재생되는 도중에 PlayableDirector.Stop()을 호출해도 OnPhase3CutsceneEnded()를 호출하게 됩니다.


그리고 컷씬이 재생되기 시작하면 playerInput.SwitchCurrentActionMap()으로 현재 활성화된 액션 맵을 "Cutscene"으로 변경하고, 컷씬이 끝나면 다시 "Player"로 변경합니다.



FadeController.cs

public enum FadeInReserveType
{
    None,
    FadeIn,
    InstantFadeIn
}

public void FadeOut(FadeInReserveType fadeInReserveType = FadeInReserveType.None)
{
    isFadeIn = false;
    isFadeOut = true;

    switch(fadeInReserveType)
    {
        case FadeInReserveType.FadeIn:
            onFadeOutComplete.AddListener(FadeIn);
            break;

        case FadeInReserveType.InstantFadeIn:
            onFadeOutComplete.AddListener(InstantFadeIn);
            break;
    }
}

void InstantFadeIn()
{
    onFadeOutComplete.RemoveAllListeners();

    color.a = 0;
    image.color = color;
    
    if(invokeOnFadeInEvents == true)
    {
        onFadeInComplete.Invoke();
        invokeOnFadeInEvents = false;
    }
}

컷씬이 페이드 아웃된 후에는 즉시 화면이 페이드 인 되어야 합니다.
이전에는 천천히 페이드 인 되는 함수만 만들어놨기 때문에, InstantFadeIn() 함수를 추가하고 FadeOut()에서 "즉시 페이드 인"도 예약할 수 있도록 enum을 추가했습니다.

즉시 페이드 인 함수는 CutsceneController.Skip()의 두 번째 줄에서 호출하고 있습니다.
(fadeController.FadeOut(FadeController.FadeInReserveType.InstantFadeIn);)



Player Input의 Cutscene - Skip 액션에 CutsceneController.OnSkip()을 바인딩합니다.

스킵 버튼을 누를 때 표시할 UI도 만들어줍니다.
이 UI는 처음에는 비활성화 상태여야 합니다. 오브젝트를 미리 꺼놓는 방법도 있긴 한데,

CutsceneController()에 이 함수를 추가하는 게 조금 더 속이 편할 것 같네요.

CutsceneController 컴포넌트에 새로 추가한 변수들을 등록합니다.
이제 게임에 들어가서, 컷씬이 실행될 때 스킵 버튼을 눌러봅시다.


게임패드의 Start 버튼이나 키보드의 ESC 버튼을 누르면 이렇게 좌측 하단에 스킵 버튼이 뜨고,

한 번 더 누르면 페이드 아웃 후 즉시 페이드 인 되면서 3페이즈로 넘어가게 됩니다.



배경음 및 컷씬 음량 조절

페이드 아웃할 때는 컷씬에서 재생되는 음량을 서서히 줄여야 합니다.

그리고 이제 배경음도 슬슬 추가해야 하니, 배경음 음량도 같이 조절하는 기능도 추가합시다.
컷씬 재생 중에는 배경음의 음량이 약간 작아져야 합니다.



AudioMixer 수정

이전에 사운드를 구현할 때 AudioMixer를 만들어놓았는데, 그 부분을 뜯어고칩시다.

사운드는 대체로 BGM(배경음) / SFX(효과음)으로 나뉘고, 효과음 중에서도 기체와 관련된 소리, 무기와 관련된 소리, 등장인물의 대사 등으로 분류할 수 있습니다.

저는 크게 4가지로 나눠보려고 합니다.

  1. BGM
  2. SFX
  3. 대사
  4. 컷씬 (SFX, 대사 통합)

컷씬에 들어갈 때는 BGM이 작아지고 SFX이 음소거되어야 합니다. (대사는 출력되지 않습니다.)
만약 컷씬이 페이드아웃되는 동안에는 컷씬 음량이 점점 줄어들어야 하고,
컷씬이 끝나면 BGM과 SFX의 음량 및 음소거 상태를 다시 원래대로 되돌려놓아야 합니다.


기존에는 엔진에 쓸 Afterburner라는 믹서 그룹만 존재했었는데, 제대로 그룹을 구성해봅시다.

맨 위에는 Master, 그 아래에 BGM, SFX, Voices, Cutscene이 있고, SFX 내부에는 이전에 만들었던 Afterburner를 놓아줍니다.

하위에 있는 믹서 그룹은 상위에 있는 믹서 그룹의 음량 등에 영향을 받습니다.
Master의 음량을 줄이면 전체 음량이 작아지고, BGM 음량만 줄이면 배경음의 음량만 작아집니다.


모든 Audio Source 컴포넌트들에 해당하는 Audio Mixer Group을 Output에 할당해줍니다.




이 믹서 그룹의 음량을 조절하기 위해서는 믹서의 파라미터를 설정 가능한 상태로 만들어야 합니다. 유니티에서는 파라미터를 "외부로 노출시키다(Expose)"라고 부릅니다.

음량을 조절할 그룹을 클릭하면 Inspector 창에 두 개의 파라미터가 나오는데,

파라미터의 이름에 대고 우클릭을 하면 Expose "???" to script 항목이 나옵니다.

일단 BGM, SFX, Cutscene 세 항목에 대해 파라미터를 노출시켜봅니다.


파라미터가 노출된 사태면 이렇게 파라미터 이름 옆에 화살표가 표시되며,
Audio Mixer 창 우측 상단의 Expose Parameters에 등록됩니다.

이 파라미터 각각에는 이름이 "MyExposedParam ()" 형식으로 자동으로 지정됩니다.

이해할 수 있을 정도로 이름을 바꿔줍니다.



음량 및 배경음 컨트롤러

AudioController.cs

public class AudioController : MonoBehaviour
{
    const float MIN_VOLUME = -80;
    const float MAX_VOLUME = 0;

    [Header("BGM")]
    [SerializeField]
    AudioClip introBGM;
    [SerializeField]
    AudioClip loopBGM;
    [SerializeField]
    float bgmPlayDelay = 1;

    [Header("Audio Control")]
    [SerializeField]
    AudioMixer audioMixer;
    
    [SerializeField]
    AudioSource bgmIntroAudioSource;
    [SerializeField]
    AudioSource bgmLoopAudioSource;

    [SerializeField]
    float cutsceneBGMVolume = -7;
    
    [SerializeField]
    float volumeLerpAmount = 0.5f;

    float bgmVolume;
    float cutsceneVolume;
    float sfxVolume;

    float targetBGMVolume;
    float targetCutsceneVolume;
    float targetSFXVolume;

    double nextEventTime;

    public float TargetBGMVolume
    {
        set { targetBGMVolume = value; }
    }
    public float TargetCutsceneVolume
    {
        set { targetCutsceneVolume = value; }
    }
    public float TargetSFXVolume
    {
        set { targetSFXVolume = value; }
    }
    
    public void OnCutsceneStart()
    {
        targetBGMVolume = cutsceneBGMVolume;
        targetCutsceneVolume = MAX_VOLUME;
        cutsceneVolume = MAX_VOLUME;
        audioMixer.SetFloat("SFXVolume", MIN_VOLUME);
        audioMixer.SetFloat("CutsceneVolume", MAX_VOLUME);
    }

    public void OnCutsceneFadeOut()
    {
        targetCutsceneVolume = MIN_VOLUME;
    }

    public void OnCutsceneEnd()
    {
        targetBGMVolume = MAX_VOLUME;
        audioMixer.SetFloat("SFXVolume", MAX_VOLUME);
    }

    void PlayLoopBGM()
    {
        bgmLoopAudioSource.clip = loopBGM;
        bgmLoopAudioSource.Play();
    }

    void Awake()
    {
        targetBGMVolume = bgmVolume = 0;
        targetCutsceneVolume = cutsceneVolume = 0;
        targetSFXVolume = sfxVolume = 0;
    }

    void Start()
    {
        bgmIntroAudioSource.clip = introBGM;
        bgmIntroAudioSource.loop = false;
        bgmIntroAudioSource.PlayScheduled(AudioSettings.dspTime + bgmPlayDelay);

        nextEventTime = AudioSettings.dspTime + bgmPlayDelay + introBGM.length;
        bgmLoopAudioSource.clip = loopBGM;
        bgmLoopAudioSource.loop = true;
        bgmLoopAudioSource.PlayScheduled(nextEventTime);
    }

    // Update is called once per frame
    void Update()
    {
        // BGM
        bgmVolume = Mathf.Lerp(bgmVolume, targetBGMVolume, Time.deltaTime);
        audioMixer.SetFloat("BGMVolume", bgmVolume);

        // Cutscene
        cutsceneVolume = Mathf.Lerp(cutsceneVolume, targetCutsceneVolume, Time.deltaTime * volumeLerpAmount);
        audioMixer.SetFloat("CutsceneVolume", cutsceneVolume);
    }
}

오디오 믹서에서 노출시킨 파라미터의 값을 변경하려면 AudioMixer.SetFloat(...)을 사용합니다.
첫 번째 매개변수로는 앞에서 설정한 매개변수의 이름, 두 번째 매개변수는 값을 전달합니다.

BGM과 컷씬 음량은 서서히 줄어들거나 올라갈 수 있어야 하므로 Update()에서 Lerp로 조정하는 방식을 사용하고, SFX는 즉시 음소거되어야 하므로 컷씬이 시작하거나 끝날 때 바로 SetFloat()를 호출합니다.


Start()에서는 두 배경음 파일을 연달아 재생시킬 수 있도록 오디오 재생을 예약합니다.

배경음은 처음에 재생할 음악과, 바로 이어서 재생되고 계속 루프될 음악 두 개로 이루어져 있습니다.
처음에 재생할 첫 번째 음악은 초기 딜레이만큼 기다린 후에 재생되고, 첫 번째 음악의 재생이 끝날 시점에 로 두 번째 음악을 재생할 수 있도록 PlayScheduled()로 예약을 걸어놓습니다.

PlayDelayed()는 정확한 타이밍에 음악을 재생하기에는 부적합합니다. 약간의 딜레이도 허용해서는 안 된다면 PlayScheduled()를 사용하는 것이 좋습니다.



CutsceneController.cs


[SerializeField]
AudioController audioController;

void Skip()
{
    audioController.OnCutsceneFadeOut();
    ...
}

void OnCutsceneStart()
{
    audioController.OnCutsceneStart();
    ...
}

void OnPhase3CutsceneEnded(PlayableDirector director)
{
    audioController.OnCutsceneEnd();
    ...
}

void OnEndingCutsceneEnded(PlayableDirector director)
{
    audioController.OnCutsceneEnd();
    ...
}

CutsceneController에서 컷씬 재생 상태에 따라 음량을 제어할 수 있도록 코드를 추가합니다.


컷씬을 실행하면 SFX의 음량이 최저로 고정되고, BGM의 음량이 -10db로 서서히 내려갑니다.
사실 다른 컷씬이 실행되는 것을 고려해서 Cutscene의 음량도 +0db로 설정되고 있습니다.

컷씬을 스킵하면 Cutscene의 음량이 점점 줄어들며, BGM의 음량이 +0db로 올라가고 SFX 볼륨이 +0db로 고정됩니다.



현재 구현 상태



페이즈 3 컷씬이 모두 만들어졌습니다.

컷씬에 사용된 음성을 구할 수 없어서 동영상에서 추출한 후 최대한 필터링을 해봤지만,
여전히 부족하긴 마찬가지네요.

좋은 방법이 없을지 계속 생각중입니다.



다음으로는 엔딩 컷씬을 만들고, 거기서 이어지는 결과 화면을 만들어야겠습니다.
거기에 메인 화면로딩 화면까지 만들면, 프로젝트의 1차 목표가 끝나게 됩니다.

당분간 복잡한 로직가지고 고통받을 일은 없을 것 같아 다행입니다.







저작권 문제가 없다고? 보스전 음악은 이쪽 문제에 해당이 안 되나?



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

4개의 댓글

comment-user-thumbnail
2023년 7월 4일

진짜 짱이세요...

답글 달기
comment-user-thumbnail
2024년 1월 15일

이런건 어디서 배우시나요?;;;
unity 공식문서만 보고 스스로 찾아가시는건가요?

1개의 답글