FOV Controller For Consistent Horizontal and Vertical FOV Across All Aspect Ratios

This is my second post, let me know if there’s anything I have to improve in redaction or general topic arrangement/readibility.

Following my previous post, I would like to share my “FOV Controller” (I honestly don’t know how to name it) as a free community resource.

I’m not really using it, so why not? It could maybe be useful for anyone that was looking for something similar just like I used to do in the past.

Just like the title says, it maintains consistent FOV across different screen aspect ratios, it’s commonly seen in competitive games like Counter Strike and Valorant, where you don’t want widescreen players to have an advantage in comparison to players with narrower screens.


Demonstration of how different aspect ratios change FOV

Here is a video demonstration of how it works:


(If you wish to see it for yourself first, here is a test place for it.)

Let me know if there’s any bug or anything!

ModuleScript

--[[
    ReplicatedStorage/FOVController (ModuleScript)
    
    Mantains consistent FOV across different screen aspect ratios.
    Gives the same horizontal and vertical FOV visibility regardless of the screen size.
    
    Author: @soydegurime (https://www.roblox.com/es/users/3919560656)
    
    How it works:
    Adjusts the camera's field of view and matrix for FOV decoupling transformation based on screen aspect ratio relative to the desired ratio.
]]

local RunService = game:GetService("RunService")
local Players = game:GetService("Players")

local FOVController = {}

-- Configuration constants
FOVController.TARGET_HORIZONTAL_FOV = 103
FOVController.TARGET_VERTICAL_FOV = 71
FOVController.TARGET_ASPECT_RATIO = 16 / 9

--[[
    Adjusts the camera's field of view and transformation based on the calculated squash value.
    
    @param squash (number) - The amount to adjust the FOV (values > 1 squash horizontally, values < 1 squash vertically)
    @param camera (Camera) - The camera to adjust (defaults to workspace.CurrentCamera)
]]

function FOVController:DecoupleFOV(squash, camera)
	local currentCamera = camera or workspace.CurrentCamera

	if not currentCamera then
		error("FOVController: Cannot adjust FOV - no camera available")
		return
	end

	local cf = currentCamera.CFrame

	if squash > 1 then
		-- Wide screen: adjust horizontal FOV
		currentCamera.CFrame = CFrame.fromMatrix(cf.Position, cf.XVector / squash, cf.YVector, cf.ZVector)
		currentCamera.FieldOfViewMode = Enum.FieldOfViewMode.Vertical
		currentCamera.FieldOfView = self.TARGET_VERTICAL_FOV
	elseif squash < 1 then
		-- Tall screen: adjust vertical FOV
		currentCamera.CFrame = CFrame.fromMatrix(cf.Position, cf.XVector, cf.YVector * squash, cf.ZVector)
		currentCamera.FieldOfViewMode = Enum.FieldOfViewMode.MaxAxis
		currentCamera.MaxAxisFieldOfView = self.TARGET_HORIZONTAL_FOV
	end
end

--[[
    Calculates the required squash value based on the current viewport dimensions.
    
    @param viewportSize (Vector2) - The dimensions of the viewport
    @return (number) - The calculated squash value
]]

function FOVController:CalculateSquashValue(viewportSize)
	local currentAspectRatio = viewportSize.X / viewportSize.Y
	return self.TARGET_ASPECT_RATIO / currentAspectRatio
end

--[[
    Updates the FOV based on the current viewport size.
    This is called on every render frame.
]]

function FOVController:UpdateFOV()
	local player = Players.LocalPlayer
	if not player then return end

	local camera = workspace.CurrentCamera
	if not camera then return end

	local viewportSize = camera.ViewportSize
	local squashValue = self:CalculateSquashValue(viewportSize)

	self:DecoupleFOV(squashValue, camera)
end

--[[
    Initializes the FOV controller by binding to the RenderStep event.
    This should be called once when your game starts.
]]

function FOVController:Init()
	-- Bind with a priority slightly higher than camera to ensure it runs after camera updates
	RunService:BindToRenderStep(
		"FOVController", 
		Enum.RenderPriority.Camera.Value + 1, 
		function() self:UpdateFOV() end
	)
	print("FOVController initialized successfully")
end

--[[
    Stops the FOV controller by unbinding from the RenderStep event.
    Call this when you want to disable the FOV controller.
]]

function FOVController:Cleanup()
	RunService:UnbindFromRenderStep("FOVController")
	print("FOVController cleaned up")
end

return FOVController

LocalScript

--[[
    FOVControllerClient.lua
    
    Client script that initializes the FOV Controller.
    Place this in StarterPlayerScripts to automatically enable the controller for all players.
]]

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local FOVController = require(ReplicatedStorage.FOVController)

-- Initialize the FOV Controller
FOVController:Init()

Instructions

  1. Create a ModuleScript in ReplicatedStorage named “FOVController”
  2. Copy the contents of the ModuleScript code
  3. Create a LocalScript in StarterPlayerScripts
  4. Copy the contents of the client code provided above

References

6 Likes