Skip to main content

⚙️ Tech Breakdown: Fixing Unreal's Horizontal Field of View

Unreal Engine applies the aspect ratio of the viewport to a fixed Horizontal Field of View. Therefore, switching from standard 16:9 to ultrawide will crop the top and bottom of the view and zoom, rather than revealing more to the left and right as you’d expect, and shrinking it to 4:3 creates an extreme fisheye effect.

FOVX

Unreal Default: content on the sides of the viewport is fixed, causing weird cropping and zooming.

FOVY

Our Camera: fixing content on the top and bottom of the viewport, like we expect.

The default is basically never what we want, but it’s hard-coded and there’s no way to override it with just configuration, so we have to patch it. 😖

Following the implementation of CineCamera, I opted to create a transparently-replacable subtype which does the adjustment just-in-time when the desired view is computed.

To start, we define two new subtypes: one for the actual camera component, and another for the wrapper camera actor.

/* A new camera component which overrides the default desired view */
UCLASS( meta=(BlueprintSpawnableComponent) )
class UMyCameraComponent : public UCameraComponent
{
	GENERATED_BODY()

public:

	virtual void GetCameraView(float DeltaTime, FMinimalViewInfo& DesiredView) override;
};

/* a wrapper for the camera actor which overrides the camera component subtype */
UCLASS()
class AMyCamera : public ACameraActor
{
	GENERATED_BODY()

public:
	AMyCamera( const FObjectInitializer& Init );

	UMyCameraComponent* GetMyCamera() const { return static_cast<UMyCameraComponent*>(GetCameraComponent()); }
};

Aside: The component/actor distinction is kind of annoying, because PlayerController and CameraManager handle camera components wrapped in camera actors slightly differently than camera components embedded in other kinds of actors. For CineCamera, it’s doubly-annoying because functionality is split between the two classes, so you lose features like Look at Tracking when embedding the component without the wrapper.

But I digress, for the implementation we do a little math in our override of GetCameraView and sprinkle a magic dependency-injection incantation in the wrapper’s constructor to instantiate the correct subcomponent (incidentally, this is also how you can change the default CharacterMovementComponent in subtypes of Character).

namespace CameraUtil
{

static const FName MEMBER_CameraComponent( "CameraComponent" );

static float CalcAspectRatio( UWorld* W )
{
	if( ULocalPlayer* Player = W->GetFirstLocalPlayerFromController() )
	{
		FVector2D Size( 0.0f, 0.0f );
		Player->ViewportClient->GetViewportSize( Size );
		if( Size.Y > KINDA_SMALL_NUMBER )
			return Size.X / Size.Y;
	}

	/* fallback to default when there's no viewport */
	return 16.0f / 9.0f;
}


static float GetFOVX( float Aspect, float FOVY ) const
{
	return 2.0f * R2D( FMath::Atan( FMath::Tan( 0.5f * D2R(FOVY) ) * Aspect ) );
}

static float GetFOVY( float Aspect, float FOVX ) const
{
	return 2.0f * R2D( FMath::Atan( FMath::Tan( 0.5f * D2R(FOVX) ) / Aspect ) );
}

}

/*virtual*/ void UMyCameraComponent::GetCameraView( float DeltaTime, FMinimalViewInfo& DesiredView ) /*override*/
{
	Super::GetCameraView( DeltaTime, DesiredView );

	/* early-out when we're letterboxed */
	if( bConstrainAspectRatio )
		return;

	/* for some reason, DesiredView.AspectRatio is not always right, so actually query the viewport */
	const float ActualAspect = CameraUtil::CalcAspectRatio( GetWorld() );
	const float DesiredFOVY = CameraUtil::GetFOVY( 16.0f / 9.0f, DesiredView.FOV );
	const float AdjustedFOVX = CameraUtil::GetFOVX( ActualAspect, DesiredFOVY );
	DesiredView.FOV = AdjustedFOVX;
}

AMyCamera::AMyCamera( const FObjectInitializer& Init )
	: Super( Init.SetDefaultSubobjectClass<UMyCameraComponent>( CameraUtil::MEMBER_CameraComponent ) )
{
}

Nothing much to write home about in this implementation, but I’ll call out a few details:

FOV Tangent Diagram

  • The Aspect Ratio applies to the Tangent of the Half-Angle of the FOV, not the angle itself, so we have to do a coordinate-space sandwich when we multiply or divide it.
  • I’ve wrapped FMath::RadiansToDegrees and FMath::DegreesToRadians in R2D and D2R macros because they’re annoyingly verbose for such common subroutines.
  • I retrieve the actual-aspect-ratio from the local player, rather than the screen, to account for Split-Screen Multiplayer (I’m assuming all split-screens have the same aspect, so we only need to look up Player 1).

If you’re really in a pinch and can’t control the subtype of CameraActor, then you can use a custom CameraModifier to post-hook the change to DesiredView with the same code. Workflow-wise this is just more annoying in the common-case, so I avoided it as the by-default solution.