⚙️ 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.
Unreal Default: content on the sides of the viewport is fixed, causing weird cropping and zooming.
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:
- 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
andFMath::DegreesToRadians
inR2D
andD2R
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.