[SOLVED] Troubles with designing a fixed viewport (i.e. maintaining a precise "zoom level" on the game world regardless of resolution)

SOLVED. Problem was very simple, see the first reply. Thanks, randomphantom!

From what I understand, Core (and basically any UE4 game) use a fixed horizontal FoV - thats what we specify in the Camera settings. That all checks out, my math has confirmed that.

My ultimate goal is to create an anamorphic viewport, or at least emulate it by pinning things to the camera - but first I need to work out some initial math before I work on actual implementation, and sadly I can't even calculate the ideal camera distance to emulate vertical fov, despite it being highschool level trigonometry :sweat_smile:

Here's a simple walkthrough with a fresh blank project to easily reproduce my problem.

  1. Create a new Empty Project (no template)
  2. Delete "Third Person Camera Settings", add a "Top Down Camera Settings"
  3. Change rotation offset from -45 to -90 for a true top-down camera
  4. Drop into the Hierarchy a "Plane 1m - One Sided" - drop it in an empty space.
    • Set scale of Plane to 10,10,1 and change the material to something visible (I chose 'Basic Material')
    • Set Z position to 0.01 of the plane (so its visible in preview instead of fighting with the terrain).

At this point, I just start a non-MP preview to move the camera into place, then I press Esc - and I leave the preview with the current viewport in editor matching the starting game camera (it changes by a few pixels, meh):

Please take note of the Plane aligned to the grid. Specifically, how the camera at this window size can fit exactly one square of the terrain grid at the top and bottom of the 1m White Plane. Consider this our desired fixed viewport - eventually, anything that doesn't have any overlap with this square (when virtually "floored") will be culled.

But for now, I just need to try and get that square to "fill to fit screen" no matter the aspect. This means I want to fix my FoV on both sides, somehow. Can't do that directly with a perspective camera so we must emulate.

See for yourself. Start up a multiplayer preview window.

...ok, the aspect is already different from the Editor viewport - we can now see more on the top and bottom - it's 1.5 squares now instead of 1. I measured the size of the window content here for Bot1 (i.e. whole window excluding border/titlebar decoration) and its a tiny bit wider than 16:9, whatever.

Now, lets change it to something closer to 4:3...

So as you can see Core uses "Vert-" FoV, meaning the Horizontal FoV is fixed, but the vertical FoV is reduced as the screen widens (and is increased as the screen narrows). This means that in resolutions wider than a standard 16:9 (e.g. 21:9 is becoming more popular), the game port appears quite zoomed in:

I understand why this is good for FPS and such. I've read about why UE4 uses fixed horizontal FoV and I think its a good choice they made. But in a top down game, we don't want this simulation of peripheral vision that aligns with reality (i.e. the horizon). Especially is the case with a top-down game where you want the PvP arena to show the same world size to any resolution, even "portrait devices" wink wink. And of course this is one way (the only decent way I know of) to emulate a 2D game, or make some neat 2.5D ones.

So, its time to treat that square plane object as our viewport. Later on it might be a trigger that is pinned to the players feet. Don't worry about that part yet.

Right, here we go:

  • Rename that 1m Plane to "_VIEWPORT"
  • Make a new script, say "ViewportManager", and also make it a child of "Top Down Camera (client)".

Contents of ViewportManager.lua:

local CAMERA = --[[---@type Camera]] script.parent

--- properties (fixed)
--- Opposite side of the triangle. Because we use a 1m square, we know 1m = 100 units, so its easy to get opposite from the viewport square
local opposite = World.GetRootObject():FindChildByName("_VIEWPORT"):GetWorldScale().y * 100 / 2
--- horizontal fov
local fovH = CAMERA.fieldOfView

---- vars (runtime)
local screenSize = Vector2.New(-1, -1) -- a definitely not-sane default to force initial update

function Tick(delta)
    --- PRIMARY REFERENCE: https://forum.unity.com/threads/camera-distance-based-on-object-size.762179/
    --- SECONDARY REFERENCE: https://stackoverflow.com/questions/14614252/how-to-fit-camera-to-object
    --- SECONDARY REFERENCE [another one]: https://gamedev.net/forums/topic/686950-what-makes-a-vertical-fov-from-a-horizontal-one/5334971/

    -- get the current screen size
    local newScreenSize = UI.GetScreenSize()

    -- only do stuff if screen size has changed
    if (newScreenSize.x ~= screenSize.x) or (newScreenSize.y ~= screenSize.y) then
        screenSize.x = newScreenSize.x
        screenSize.y = newScreenSize.y
        -- print new aspect ratio (as a ratio string)
        print("New aspect ratio > " .. newScreenSize.x / newScreenSize.y)

        -- first calculate the vertical FoV
        local fovV = 2 * math.atan(screenSize.y/screenSize.x * math.tan(math.rad(fovH)/2))
        -- ...and convert to degrees then round up
        local fovVdegrees = math.deg(fovV)
        -- this seems absolutely correct. When going fullscreen in a 16:9 res, the fovV will be 59 (assuming fovH of 90)
        local fovVdegreesRounded = math.ceil(fovVdegrees)
        print("New fovV: " .. fovVdegreesRounded .. " degrees (rounded up from " .. fovVdegrees .. "; original rads = " .. fovV .. ")")

        -- now that we have foV, we can calculate cemera distance - adjacent - using THAT angle - in other words the hypotenuse is vertical now
        local angle = math.ceil(fovVdegrees / 2)
        local adjacent = opposite / math.tan(math.rad(angle))
        print ("New distance: " .. adjacent)
        CAMERA.currentDistance = adjacent

This is ALMOST correct. It is very close to keeping the square locked to viewport, but it seems to be "snapping to nearest something". Or maybe the camera doesnt project a flat pyramid but a rounded cone or something like that that's beyond me.

Just try a multiplayer preview window again and make it nice and wide then narrow. I hope it makes sense when I say it seems like there's another variable in the Core viewport projection that i'm not accounting for.

What am I missing?

Thank you for reading!

~ [Minor edits to code and justifications/reasoning, all looks good now]

I am so daft.

My camera was still attached to the local player.

All I had to do was subtract some numbers from the final adjacent number to account for the camera being attached around the middle of the player:

local adjacent = (opposite / math.tan(math.rad(angle)))
adjacent = adjacent - 1 - 168.5
print ("New distance: " .. adjacent)
CAMERA.currentDistance = adjacent

Thanks to randomphantom on the Discord for catching this for me :sweat_smile: