언리얼엔진/FPS 프로젝트

[UE5 | FPS] 수평 FOV와 수직 FOV, 그리고 뷰모델

mstone8370 2024. 5. 8. 19:45

 

 

FPS에서 FOV값은 마우스 감도 다음으로 중요한 옵션이 아닐까 싶다.

FOV가 너무 좁으면 주변 정보를 얻기 어렵고, 너무 넓으면 적이 작게보여서 조준하기가 어려워진다.

따라서 본인에게 맞는 적절한 값을 설정하는게 중요한데, 몇몇 게임들을 해보면 이 값의 범위도 다르고 같은 값으로 설정해도 체감되는게 다르기도 하다.

만약 서로 다른 게임에서 동일한 느낌의 FOV를 설정했는데 그 값의 차이가 크다면 이 게임들은 FOV값을 서로 다르게 사용하는것일수도 있다.

 

FOV에는 수직 FOV와 수평 FOV가 있다.

값을 각도라고 했을 때, 수직 FOV는 위아래의 각도를, 수평 FOV는 좌우의 각도를 의미한다.

사람은 위아래보다는 주로 좌우를 보게되므로 게임에서도 좌우를 기준으로 FOV값을 설정한다.

그래서인지 FPS의 설정에서 보이는 FOV값은 수평 FOV값인 경우가 많다.

하지만 수직 FOV가 쓰이지 않는건 아니다.

겉으로는 수평 FOV값을 보여주더라도 내부에서는 수직 FOV를 사용하고있는 경우가 많다.

 


 

언리얼 엔진에서도 마찬가지다.

이러한 내용은 UCameraComponent.h 파일의 주석을 통해 확인할 수 있다.

// Camera/CameraComponent.h

    /** 
     * The horizontal field of view (in degrees) in perspective mode (ignored in Orthographic mode)
     *
     * If the aspect ratio axis constraint (from ULocalPlayer, ALevelSequenceActor, etc.) is set to maintain vertical FOV, the AspectRatio
     * property will be used to convert this property's value to a vertical FOV.
     *
     */
    UPROPERTY(Interp, EditAnywhere, BlueprintReadWrite, Category = CameraSettings, meta = (UIMin = "5.0", UIMax = "170", ClampMin = "0.001", ClampMax = "360.0", Units = deg))
    float FieldOfView;

 

UCameraComponentFieldOfView 값은 수평 FOV를 의미하지만 설정에 따라 수직 FOV로 변환되서 수직 FOV값을 유지한다고 적혀있다.

 

이 정보를 통해 ULocalPlayer를 찾아보면 AspectRatioAxisConstraint라는 enum타입 변수가 있고, 기본값인 AspectRatio_MaintainYFOV를 사용하고있다.

화면에서 Y축은 위아래이므로 수직 FOV가 고정됨을 의미한다.

// Engine/LocalPlayer.h

    /** How to constrain perspective viewport FOV */
    UPROPERTY(config)
    TEnumAsByte<enum EAspectRatioAxisConstraint> AspectRatioAxisConstraint;




// Engine/EngineTypes.h

/** Enum describing how to constrain perspective view port FOV */
UENUM()
enum EAspectRatioAxisConstraint : int
{
    AspectRatio_MaintainYFOV UMETA(DisplayName="Maintain Y-Axis FOV"),
    AspectRatio_MaintainXFOV UMETA(DisplayName="Maintain X-Axis FOV"),
    AspectRatio_MajorAxisFOV UMETA(DisplayName="Maintain Major Axis FOV"),
    AspectRatio_MAX,
};

 

이렇게 설정되어있기 때문에 언리얼 엔진에서 뷰포트의 크기를 변경하면 아래처럼 수직 FOV는 유지되고, 수평 FOV가 변하는것을 확인할 수 있다.

 

수직 FOV 유지

 

이 설정을 카메라 컴포넌트에서 오버라이드해서 수평 FOV를 유지되게 하면 아래와 같이 된다.

 

수평 FOV를 유지하게 설정
수평 FOV 유지

두 방식 중에 개인적으로는 수직 FOV를 유지하는 방법이 더 자연스럽다고 생각한다.

이 부분은 게임의 방향에 따라서 달라질 수 있는 부분이지만 뷰모델의 FOV는 주로 수직 FOV를 고정한다.

바로 위 예시의 뷰모델을 자세히 보면 카메라와 다르게 작동한다는걸 확인할 수 있다.

 


 

FPS의 플레이어가 조작하는 캐릭터는 팔과 캐릭터가 들고있는 무기만 보이는데 이건 조작하는 플레이어만 보이는 뷰모델이다.

이 모델의 위치는 주로 화면에 고정되어있으므로 일종의 UI와 같은 느낌을 준다.

따라서 화면의 비율이 달라져도 일관된 모습을 보여줘야한다.

그러기 위해선 수직 FOV를 유지해야 다양한 모니터의 비율에서 일관된 모습을 보여줄 수 있다.

특히 와이드 비율 모니터는 좌우가 넓어진 것도 맞지만, 위아래가 짧아진 비율이라고 볼 수도 있다.

이 경우 뷰모델이 수평 FOV를 유지하면 뷰모델이 확대되어 보이고, 하단부가 가려지게 되어 답답한 느낌을 주게된다.

 

뷰모델이 잘리는 경우
뷰모델 비교

 

이렇게 뷰모델의 수직 FOV를 유지하게 하려면 FOV 값을 별도로 지정해야한다.

 

뷰모델의 FOV를 설정하는 방법은 아래 영상 덕분에 가능하게 됐다.

https://youtu.be/zqfzvHCcvZs?si=UQv6ILH6oPDXD9_V

글 작성일 기준으로는 비공개된 영상이지만 혹시 몰라서 링크를 달아둔다.

 

아니면 이 프로젝트를 통해 이 머티리얼 함수를 확인할 수 있다.

https://github.com/Mstone8370/UE-Weapon-Clipping-Fix-Material-Function

 

GitHub - Mstone8370/UE-Weapon-Clipping-Fix-Material-Function

Contribute to Mstone8370/UE-Weapon-Clipping-Fix-Material-Function development by creating an account on GitHub.

github.com

 

무기 메시가 벽을 뚫어서 가려지는걸 방지해주는 머티리얼인데 여기에서 FOV값도 설정할 수 있다.

여기에서 설정하는 FOV값은 수평 FOV라서 이 값을 수직 FOV로 변환해서 사용해야한다.

 


 

FOV를 변환하는 식은 다음과 같다.

 

\[{FOV}'=2*atan(tan({FOV}/2)*{AspectRatio})\]

 

Aspect ratio는 종횡비 또는 화면비율이다.

만약 수평 FOV에서 수직 FOV로 변환하고 싶다면, 종횡비를 (세로 길이 / 가로 길이)로 계산하면 된다.

수직 FOV에서 수평 FOV로 변환할 때에는 종횡비를 (가로 길이 / 세로 길이)로 계산하면 된다.

 

언리얼 엔진에서는 이렇게 계산하면 된다.

UKismetMathLibrary::DegAtan(UKismetMathLibrary::DegTan(FOV / 2) * AspectRatio) * 2;

 

이 프로젝트의 뷰모델은 16:9 비율 화면에서 수평 FOV값이 80일때를 기준으로 제작되었다.

따라서 이때를 기준으로 수직 FOV를 계산해서 목표 수직 FOV값으로 정한다.

이 목표 수직 FOV값을 현재의 뷰포트 화면의 비율에 맞게 수평 FOV값으로 변환해서 머티리얼에 지정해주면 수직 FOV를 유지할 수 있다.

 

뷰모델이 제작된 환경의 FOV도 수평 FOV고, 머티리얼도 수평 FOV를 사용하는 상황에 수직 FOV를 유지해야하므로 변환 과정을 두번 거치게 된다.

 


 

다음은 언리얼 엔진에 적용하는 과정이다.

게임중에는 FOV값이 바뀌는 경우가 자주 있으므로, 스켈레탈 메시 컴포넌트를 상속받아서 뷰모델 전용 컴포넌트를 생성했다.

 

각종 수치들은 수평 FOV값을 사용하기에 수평 FOV값을 기준으로 사용하고, 위에서 설명했듯이 두번 변환한 값을 설정한다.

FOV값을 바꿀때는 부드럽게 보간할 예정이므로, 목표 수평 FOV(TargetHFOV)와 현재 수평 FOV(CurrentHFOV)를 따로 다룬다.

double UNLViewSkeletalMeshComponent::ConvertFOVByAspectRatio(double BaseFOV, double AspectRatio)
{
    return UKismetMathLibrary::DegAtan(UKismetMathLibrary::DegTan(BaseFOV / 2) * AspectRatio) * 2;
}

void UNLViewSkeletalMeshComponent::UpdateFOV()
{
    float FinalHFOV = CurrentHFOV;

    if (GetViewportClient())
    {
        VFOV = ConvertFOVByAspectRatio(CurrentHFOV, 9 / 16.f);

        FVector2D ViewportSize;
        GetViewportClient()->GetViewportSize(ViewportSize);
        FinalHFOV = ConvertFOVByAspectRatio(VFOV, ViewportSize.X / ViewportSize.Y);
    }

    // Set FOV value
    for (UMaterialInterface* MaterialInstance : GetMaterials())
    {
        if (UMaterialInstanceDynamic* MID = Cast<UMaterialInstanceDynamic>(MaterialInstance))
        {
            MID->SetScalarParameterValue(FName("FOV"), FinalHFOV);
        }
    }
}

UGameViewportClient* UNLViewSkeletalMeshComponent::GetViewportClient()
{
    if (!ViewportClient)
    {
        ViewportClient = GEngine ? GEngine->GameViewport : nullptr;
    }
    return ViewportClient;
}

런타임에 머티리얼 인스턴스의 파라미터 값을 바꾸기 위해 머티리얼 인스턴스 다이나믹을 사용해야하고, 이것과 관련된 내용은 생략한다.

 

뷰포트의 크기가 바뀌었을때에도 뷰모델의 수직 FOV를 유지하기 위해 뷰포트의 리사이즈 델리게이트에 함수를 연결한다.

이 과정은 플레이어의 캐릭터에서 진행했다.

void ANLPlayerCharacter::BeginPlay()
{
    // ...
    
    // 뷰포트 리사이즈 이벤트 델리게이트에 함수 추가
    FViewport::ViewportResizedEvent.AddUObject(this, &ANLPlayerCharacter::OnViewportResized);
    
    // ...
}

void ANLPlayerCharacter::OnViewportResized(FViewport* InViewport, uint32 arg)
{
    /**
    * [PIE]
    * 여러개의 뷰포트가 있는 경우 하나의 뷰포트의 크기가 변경되더라도 모든 뷰포트에서 각각 이 함수가 호출됨.
    * 따라서 크기가 변경된 뷰포트를 구분해야함.
    * 게임 인스턴스는 각 게임마다 생성되므로, 뷰포트 하나당 하나의 게임 인스턴스가 생성됨.
    * 게임 인스턴스는 UGameViewportClient 객체를 가지고있고, 이걸통해 이 게임 인스턴스가 담당하는 뷰포트가 무엇인지 구분가능함.
    */
    if (UGameInstance* GI = GetGameInstance())
    {
        if (UGameViewportClient* ViewportClient = GI->GetGameViewportClient())
        {
            FViewport* Viewport = ViewportClient->Viewport;
            if (Viewport && Viewport == InViewport)
            {
                ArmMesh->UpdateFOV();
                ViewWeaponMesh->UpdateFOV();
            }
        }
    }
}

OnViewportResized 함수에 주석이 길게 달려있는데 이건 게임이 동시에 두개 이상 켜져있는 경우에 하나의 뷰포트 크기만 변경되어도 모든 뷰포트에서 이벤트를 받는걸 확인했기 때문이다.

따라서 크기가 바뀌지 않은 뷰포트도 영향을 받아 변경된 뷰포트의 비율에 맞게 FOV값이 변경되는 문제가 있었다.

 

모든 뷰포트가 리사이즈 이벤트를 받는 문제


이 문제는 위에 적혀있는 UpdateFOV 함수에서 ViewportClient를 통해 각자의 게임 인스턴스가 담당하는 뷰포트의 크기를 받아오는 방법으로 해결해서 위와 같은 문제는 발생하지 않는다.

하지만 각자의 뷰포트 크기를 받아올 뿐, 크기가 변경되지 않은 나머지 뷰포트들도 이벤트를 받아서 FOV값을 계산하는건 마찬가지이므로, 이벤트를 받았을때 크기가 변경된 뷰포트만 찾아서 FOV값을 계산하게 했다.

 

크기가 바뀐 뷰포트만 FOV 계산

 


 

이렇게 해서 화면의 크기와 비율이 달라져도 뷰모델의 수직 FOV를 유지하게 됐다.