PcoWSkbVqDnWTu_dm2ix
We use cookies on this site to enhance your user experience

Three Quarters Camera View

Three Quarters Camera View

Jul 02 2018, 2:54 PM PST 10 min

A three-fourths camera is a type of camera used in many RPG and strategy games. It features a camera looking down on a level at an angle, usually at a fixed height. This article describes how to implement an example three-fourths camera which integrates with the default Roblox controls.

IsoCamExample.png

Inserting Three Fourths Camera

The Three Fourths camera script needs a slightly modified version of the current camera PlayerScript in order to run. To get a copy of this script, simply start a new instance of Studio, click Play, and look inside Players > Player1 > PlayerScripts, and copy the entire LocalScript called CameraScript (including all of its children).

DefaultCameraScript.png

Stop the game and place the copied script inside StarterPlayer > StarterPlayerScripts:

NewCameraPlayerScript.png

Insert the following code into a ModuleScript called ThreeFourthsCamera. Insert this module script inside the RootScript.

local PlayersService = game:GetService('Players')
local UserInputService = game:GetService('UserInputService')
local RootCameraCreator = require(script.Parent)
local RunService = game:GetService('RunService')
local MODES {
	["Locked"] = true;
	["Free"] = true;
}
local MAX_HISTORY_COUNT = 7
local MAX_HISTORY_DURATION = 0.12
local ZERO_VECTOR = Vector3.new(0,0,0)
local UP_VECTOR = Vector3.new(0,1,0)
local XZ_VECTOR = Vector3.new(1,0,1)

local function CreateVelocityCalculator()
	local this = {}

	local history = {}

	function this:AddMovementEvent(position, timestamp)
		local now = tick()
		timestamp  = timestamp or now

		local historyObject = {['position'] = position; ['time'] = timestamp;}

		table.insert(history, historyObject)
		-- NOTE: we could maintain a sorted list but this is easier to read
		table.sort(history, function(a,b) return a['time'] < b['time'] end)
		
		while #history > MAX_HISTORY_COUNT do
			table.remove(history, 1)
		end
		while #history > 0 and history[1]['time'] + MAX_HISTORY_DURATION < now do
			table.remove(history, 1)
		end
	end

	function this:CalculateVelocity()
		local result = ZERO_VECTOR
		if history[1] then
			local count = #history
			local startTime = history[1]['time']
			local startPosition = history[1]['position']

			-- Skip last event because it may have bad data
			if count > 4 then
				count = count - 1
			end

			for i = 2, count do
				local historyObject = history[i]
				local timeDelta = historyObject['time'] - startTime

				if timeDelta > 0 then
					local positionDelta = historyObject['position'] - startPosition
					local velocity = positionDelta / timeDelta

					if result == ZERO_VECTOR then
						result = velocity
					else
						result = (result + velocity) * 0.5
					end
				end
			end
		end
		return result
	end

	function this:Reset()
		history = {}
	end

	return this
end

local function IsFinite(num)
	return num == num and num ~= 1/0 and num ~= -1/0
end

local function clamp(low, high, num)
	if low <= high then
		return math.min(high, math.max(low, num))
	end
	return num
end

local function dampen(amplitude, targetPosition, startTime, duration)
	local timeDelta = tick() - startTime
	local position = targetPosition - amplitude * math.exp(-timeDelta / duration)
	-- Originally total duration was 6 by changed to 7.7 to make last value jump smoother
	-- and it probably makes Sorcus happier
	if timeDelta > (7.7 * duration) then
		return targetPosition
	end
	return position
end

-- K is a tunable parameter that changes the shape of the S-curve
-- the larger K is the more straight/linear the curve gets
local k = 0.35
local lowerK = 0.8
local function SCurveTranform(t)
	t = clamp(-1,1,t)
	if t >= 0 then
		return (k*t) / (k - t + 1)
	end
	return -((lowerK*-t) / (lowerK + t + 1))
end

-- DEADZONE
local DEADZONE = 0.1
local function toSCurveSpace(t)
	return (1 + DEADZONE) * (2*math.abs(t) - 1) - DEADZONE
end

local function fromSCurveSpace(t)
	return t/2 + 0.5
end

local function gamepadLinearToCurve(thumbstickPosition)
	local function onAxis(axisValue)
		local sign = 1
		if axisValue < 0 then
			sign = -1
		end
		local point = fromSCurveSpace(SCurveTranform(toSCurveSpace(math.abs(axisValue))))
		point = point * sign
		return clamp(-1,1,point)
	end
	return Vector2.new(onAxis(thumbstickPosition.x), onAxis(thumbstickPosition.y))
end

local function constrainPointToAABB(position, boundingRegion)
	local boxMin = boundingRegion.CFrame.p - boundingRegion.Size/2
	local boxMax = boundingRegion.CFrame.p + boundingRegion.Size/2

	return Vector3.new(clamp(boxMin.X, boxMax.X, position.X), 0, clamp(boxMin.Z, boxMax.Z, position.Z))
end

local function ScreenToWorldScale(distance)
	local camera = workspace.CurrentCamera
	if camera then
		distance = distance or (camera.CoordinateFrame.p - camera.Focus.p).magnitude
		
		local aspectRatio = camera.ViewportSize.X / camera.ViewportSize.Y
		local height = 2 * distance * math.tan(math.rad(camera.FieldOfView) / 2)
		local width = aspectRatio * height

		return Vector2.new(width, height) / camera.ViewportSize
	end
	return Vector2.new(0,0)
end

local function ScreenToViewport(screenPosition)
	local camera = workspace.CurrentCamera
	if camera then
		local testWorldPt = camera.CoordinateFrame.p + camera.CoordinateFrame.lookVector
		local testViewPt = camera:WorldToViewportPoint(testWorldPt)
		local testScreenPt = camera:WorldToScreenPoint(testWorldPt)
		local diff = testViewPt - testScreenPt
		return screenPosition + diff
	end
end

local function CreateThreeFourthsCamera()
	local module = RootCameraCreator()

	module.ZoomEnabled = true
	module.PanEnabled = true
	module.RotateEnabled = true
	
	local useEdgeBumpPanning = true
	local mousePanInputType = Enum.UserInputType.MouseButton2 -- Enum.UserInputType.MouseButton1

	local pitch = math.rad(-55)
	local roll = math.rad(0)
	local yaw = math.rad(45)
	
	local regionBounds = Region3.new(Vector3.new(-300, 0, -300), Vector3.new(300, 0, 300))
	local freeSubjectPosition = Vector3.new()
	local panSensitivity = 2.5
	local edgePanSensitivity = 25
	local mouseRotateSensitivity = 5
	local touchRotateSensitivity = 1
	local thumbstickSensitivity = 0.45
	local thumbstickRotateSensitivity = 1.75
	local thumbstickZoomSensitivity = 88

	local mode = "Free"

	local velocityCalculator = CreateVelocityCalculator()
	
	local currentPanRoutine = nil
	
	
	local function setFreeModeFocusPosition(position)	
		position = constrainPointToAABB(position, regionBounds)
		freeSubjectPosition = position
	end

	local function onModeChanged(newMode)
		if mode == "Free" then
			local camera = 	workspace.CurrentCamera
			if camera then
				setFreeModeFocusPosition(camera.Focus.p)
			end
		else
			if UserInputService.MouseBehavior ~= Enum.MouseBehavior.LockCenter then
				UserInputService.MouseBehavior = Enum.MouseBehavior.Default
			end
		end
	end

	function module:SetMode(newMode)
		if MODES[newMode] then
			if newMode ~= mode then
				mode = newMode
				onModeChanged(mode)
			end
		end
	end

	local superGetSubjectPosition = module.GetSubjectPosition
	function module:GetSubjectPosition()
		if mode == "Locked" then
			return superGetSubjectPosition(self)
		end
		local newLookVector = self:ComputerLookVector()

		return freeSubjectPosition
	end
	
	function module:StartVelocityPan(velocity)
		local scaleFactor = .2
		local amplitude = velocity * scaleFactor
		local targetPosition = amplitude
		local duration = 0.25
		local start = tick()
		local currPosition = Vector3.new()
		
		local routine = nil
		routine = coroutine.create(function()
			while currPosition ~= targetPosition and routine == currentPanRoutine do
				local newPosition = dampen(amplitude, targetPosition, start, duration)
				local delta = newPosition - currPosition
				currPosition = newPosition

				local threeDDelta = CFrame.Angles(0, yaw, 0):vectorToWorldSpace(Vector3.new(delta.X, 0, delta.Y)) * Vector3.new(1,0,1)
				local subjectPosition = module:GetSubjectPosition()
				if subjectPosition then
					setFreeModeFocusPosition(subjectPosition - threeDDelta)
				end
				
				RunService.RenderStepped:wait()
			end
			if routine == currentPanRoutine then
				currentPanRoutine = nil
			end
		end)
		
		currentPanRoutine = routine
		coroutine.resume(routine)
	end
	
	function module:CancelVelocityPan()
		currentPanRoutine = nil
	end

	local function PanByScreenDelta(delta, sensitivity)
		sensitivity = sensitivity or 1
		local ratio = ScreenToWorldScale()
		local subjectPosition = module:GetSubjectPosition()
		if delta ~= ZERO_VECTOR and subjectPosition then
			local threeDDelta = CFrame.Angles(0, yaw, 0):vectorToWorldSpace(Vector3.new(delta.X * ratio.X, 0, delta.Y * ratio.Y) * sensitivity) * XZ_VECTOR
			setFreeModeFocusPosition(subjectPosition - threeDDelta)
		end
	end

	local InputBeganConn, InputChangedConn, InputEndedConn, TouchRotateConn, GamepadDisconnectedConn = nil, nil, nil, nil, nil
	local mouseTrackRoutine = nil
	
	local mouseButton1Down = false
	local mouseButton2Down = false
	local lastPanMousePosition = nil
	local mouseMovementInputObject = nil
	local freshDelta = false
	local thumbstick1Position = Vector2.new(0,0)
	local thumbstick2Position = Vector2.new(0,0)
	
	local function UpdateMouseMovement(input, processed)		
		local position = input.Position
		local currPos = position
		local delta = ZERO_VECTOR
		if lastPanMousePosition then
			if freshDelta then
				delta = input.Delta
			end
			currPos = lastPanMousePosition + delta
			lastPanMousePosition = currPos
			freshDelta = false
		else
			lastPanMousePosition = position
		end
				
		
		if mouseButton1Down and mouseButton2Down then
			if module.RotateEnabled then
				local ratio = workspace.CurrentCamera and 1 / workspace.CurrentCamera.ViewportSize.X
				if ratio and IsFinite(ratio) then
					local deltaRotation = -delta.X * ratio * mouseRotateSensitivity
					yaw = yaw + deltaRotation
				end
			end
		elseif (mousePanInputType == Enum.UserInputType.MouseButton2 and mouseButton2Down) or
				(mousePanInputType == Enum.UserInputType.MouseButton1 and mouseButton1Down) then
			if module.PanEnabled then
				PanByScreenDelta(delta, panSensitivity)
				velocityCalculator:AddMovementEvent(currPos, tick())
			end
		end
	end
	
	local fingerTouches = {}
	local NumUnsunkTouches = 0
	local lastTouchPos = nil

	local function OnTouchBegan(input, processed)
		fingerTouches[input] = processed
		if not processed then
			NumUnsunkTouches = NumUnsunkTouches + 1
			lastTouchPos = nil
		end
	end

	local function OnTouchChanged(input, processed)
		if fingerTouches[input] == nil then
			fingerTouches[input] = processed
			if not processed then
				NumUnsunkTouches = NumUnsunkTouches + 1
				lastTouchPos = nil
			end
		end

		if NumUnsunkTouches >= 1 and NumUnsunkTouches < 4 then
			local avgPos = Vector3.new()
			for touch, wasSunk in pairs(fingerTouches) do
				if not wasSunk then
					avgPos = avgPos + touch.Position
				end
			end
			avgPos = avgPos / NumUnsunkTouches
			if module.PanEnabled then
				if lastTouchPos == nil then
					velocityCalculator:Reset()
				else
					local delta = avgPos - lastTouchPos
					PanByScreenDelta(delta)
					velocityCalculator:AddMovementEvent(avgPos, tick())
				end
			end
			lastTouchPos = avgPos
		end
	end

	local function OnTouchEnded(input, processed)
		if fingerTouches[input] == false then
			lastTouchPos = nil
		end

		if fingerTouches[input] ~= nil and fingerTouches[input] == false then
			NumUnsunkTouches = NumUnsunkTouches - 1
			if NumUnsunkTouches == 0 then
				if module.PanEnabled then
					local ratio = ScreenToWorldScale()
					local velocity = velocityCalculator:CalculateVelocity() * Vector3.new(ratio.X, ratio.Y, 0)
					module:StartVelocityPan(velocity)
				end
			end
		end
		fingerTouches[input] = nil
	end
	
	local superSetEnabled = module.SetEnabled
	function module:SetEnabled(newState)
		local wasEnabled = self.Enabled
		superSetEnabled(self, newState)

		if newState ~= wasEnabled then
			if self.Enabled then
				InputBeganConn = UserInputService.InputBegan:connect(function(input, processed)
					if not processed then
						if input.UserInputType == Enum.UserInputType.MouseButton1 then
							mouseButton1Down = true
							velocityCalculator:Reset()
							currentPanRoutine = nil
						elseif input.UserInputType == Enum.UserInputType.MouseButton2 then
							mouseButton2Down = true
							velocityCalculator:Reset()
							currentPanRoutine = nil
						end
						if module.PanEnabled then
							if input.UserInputType == mousePanInputType then
								if UserInputService.MouseBehavior ~= Enum.MouseBehavior.LockCenter then
									UserInputService.MouseBehavior = Enum.MouseBehavior.LockCurrentPosition
								end
							end
						end
					end
					if input.UserInputType == Enum.UserInputType.Touch then
						OnTouchBegan(input, processed)
					end
				end)
				InputChangedConn = UserInputService.InputChanged:connect(function(input, processed)
					if input.UserInputType == Enum.UserInputType.MouseMovement then
						freshDelta = true
						mouseMovementInputObject = input
						UpdateMouseMovement(input, processed)
					elseif input.UserInputType == Enum.UserInputType.Touch then
						freshDelta = true
						OnTouchChanged(input, processed)
					elseif input.UserInputType == Enum.UserInputType.Gamepad1 then
						if input.KeyCode == Enum.KeyCode.Thumbstick1 then
							thumbstick1Position = Vector2.new(input.Position.X, input.Position.Y)
						elseif input.KeyCode == Enum.KeyCode.Thumbstick2 then
							thumbstick2Position = Vector2.new(input.Position.X, input.Position.Y)
						end						
					end
				end)
				InputEndedConn = UserInputService.InputEnded:connect(function(input, processed)
					if input.UserInputType == Enum.UserInputType.MouseButton1 then
						mouseButton1Down = false
					elseif input.UserInputType == Enum.UserInputType.MouseButton2 then
						mouseButton2Down = false
					elseif input.UserInputType == Enum.UserInputType.Touch then
						OnTouchEnded(input, processed)
					elseif input.UserInputType == Enum.UserInputType.Gamepad1 then
						if input.KeyCode == Enum.KeyCode.Thumbstick1 then
							thumbstick1Position = Vector2.new(input.Position.X, input.Position.Y)
						elseif input.KeyCode == Enum.KeyCode.Thumbstick2 then
							thumbstick2Position = Vector2.new(input.Position.X, input.Position.Y)
						end
					end
					
					if input.UserInputType == mousePanInputType then
						if UserInputService.MouseBehavior ~= Enum.MouseBehavior.LockCenter then
							UserInputService.MouseBehavior = Enum.MouseBehavior.Default
						end
						lastPanMousePosition = nil
						if module.PanEnabled then
							local ratio = ScreenToWorldScale()
							local velocity = velocityCalculator:CalculateVelocity() * Vector3.new(ratio.X, ratio.Y, 0) * panSensitivity
							self:StartVelocityPan(velocity)
						end
					end
				end)
				
				-- ROTATE CODE
				local LastRotateValue = nil
				TouchRotateConn = UserInputService.TouchRotate:connect(function(touchPositions, rotation, velocity, state, processed)
					if self.RotateEnabled and not processed then
						if #touchPositions == 2 then
							if module.RotateEnabled then
								if LastRotateValue then
									local deltaRotation = rotation - LastRotateValue	
									yaw = yaw + deltaRotation * touchRotateSensitivity
								end
							end
							LastRotateValue = rotation
						end
					end
	
					if state == Enum.UserInputState.End then
						LastRotateValue = nil
					end
				end)
				GamepadDisconnectedConn = UserInputService.GamepadDisconnected:connect(function(gamepadNum)
					if gamepadNum == Enum.UserInputType.Gamepad1 then
						-- Don't keep panning if the gamepad is disconnected while the stick was rotated
						thumbstick1Position = Vector2.new(0,0)
						thumbstick2Position = Vector2.new(0,0)
					end
				end)
			else
				if InputBeganConn then InputBeganConn:disconnect() InputBeganConn = nil end
				if InputChangedConn then InputChangedConn:disconnect() InputChangedConn = nil end
				if InputEndedConn then InputEndedConn:disconnect() InputEndedConn = nil end
				if TouchRotateConn then TouchRotateConn:disconnect() TouchRotateConn = nil end
				if GamepadDisconnectedConn then GamepadDisconnectedConn:disconnect() GamepadDisconnectedConn = nil end
				mouseButton1Down = false
				mouseButton2Down = false
				freshDelta = false
				mouseTrackRoutine = false
				mouseMovementInputObject = nil
				lastPanMousePosition = nil
				thumbstick1Position = Vector2.new(0,0)
				thumbstick2Position = Vector2.new(0,0)
			end
		end
	end

	function module:ComputerLookVector()
		return (CFrame.Angles(0, yaw, 0) * CFrame.Angles(pitch, 0, 0) * CFrame.Angles(0, 0, roll)).lookVector
	end
	
	function module:CalculateCameraCFrame()
		local player = PlayersService.LocalPlayer

		local subjectPosition = self:GetSubjectPosition()
		if subjectPosition and player then
			local zoom = self:GetCameraZoom()
			if zoom < 0.5 then
				zoom = 0.5
			end
			local newLookVector = self:ComputerLookVector()

			local focus = CFrame.new(subjectPosition)
			local coordinateFrame = CFrame.new(focus.p - (zoom * newLookVector), focus.p)
			return focus, coordinateFrame
		end
	end

	local lastUpdate = tick()
	function module:Update()
		local now = tick()
				
		local camera = 	workspace.CurrentCamera
		local timeDelta = now - lastUpdate		
		local deltaFactor = math.min(0.5, timeDelta)
		
		if camera then
			if module.PanEnabled then
				if thumbstick1Position ~= Vector2.new(0,0) then
					PanByScreenDelta(Vector2.new(-1, 1) * gamepadLinearToCurve(thumbstick1Position) * deltaFactor * camera.ViewportSize, thumbstickSensitivity)
				end
			end
			if thumbstick2Position ~= Vector2.new(0,0) then
				if module.RotateEnabled and math.abs(thumbstick2Position.X) > math.abs(thumbstick2Position.Y) then
					local deltaRotation = -gamepadLinearToCurve(thumbstick2Position).X * thumbstickRotateSensitivity * deltaFactor
					yaw = yaw + deltaRotation
				elseif module.ZoomEnabled then
					local zoomAmount = -gamepadLinearToCurve(thumbstick2Position).Y * thumbstickZoomSensitivity
					self:ZoomCameraBy(zoomAmount, deltaFactor)
				end
			end
		end
		
		if module.PanEnabled and useEdgeBumpPanning then
			local mouseInputPos = mouseMovementInputObject and mouseMovementInputObject.Position
			if mouseInputPos and camera then
				local deltaX, deltaY = 0, 0
				local viewportMousePos = ScreenToViewport(mouseInputPos)
				if viewportMousePos then
					if viewportMousePos.X < 7 then
						deltaX = 1
					elseif viewportMousePos.X > camera.ViewportSize.X - 7 then
						deltaX = -1
					end
					if viewportMousePos.Y < 7 then
						deltaY = 1
					elseif viewportMousePos.Y > camera.ViewportSize.Y - 7 then
						deltaY = -1
					end
					PanByScreenDelta(Vector2.new(deltaX, deltaY) * 30 * deltaFactor * edgePanSensitivity)
				end
			end
		end
		
		if mouseMovementInputObject and not (mouseButton1Down and mouseButton2Down) then
			if (mousePanInputType == Enum.UserInputType.MouseButton2 and mouseButton2Down) or
				(mousePanInputType == Enum.UserInputType.MouseButton1 and mouseButton1Down) then
					-- NOTE: This is so we can sample mouse movements every frame, not just when they are fired
					UpdateMouseMovement(mouseMovementInputObject, false)
			end
		end

		local newFocus, newCFrame = self:CalculateCameraCFrame()
		if camera and newFocus and newCFrame then
			camera.Focus = newFocus
			camera.CoordinateFrame = newCFrame
			self.LastCameraTransform = camera.CoordinateFrame
		end

		lastUpdate = now
	end

	return module
end

return CreateThreeFourthsCamera

InsideRootScript.png

Inside of the CameraScript LocalScript, we need to declare the new camera. Towards the top of the script, where the other cameras are declared, insert the line

local ThreeFourthsCamera = require(RootCamera.ThreeFourthsCamera)()

The simplest way to implement the three-fourths camera from here is to make the CameraScript ignore the other camera modes and always for the Three Fourths Camera to be enabled. To do this, modify the SetEnabledCamera function inside of CameraScript to look like this:

local function SetEnabledCamera(newCamera)
	if not EnabledCamera then
		EnabledCamera = ThreeFourthsCamera
		EnabledCamera:SetEnabled(true)
	end
end

Customization

The Three Fourths camera comes with several configurations that you can change to customize the experience. All of these configurations are in the CreateThreeFourthsCamera function. Some of the key configurations are:

Configuration Description
ZoomEnabled Sets if the player can zoom the camera.
PanEnabled Sets if the player can pan the camera.
RotateEnabled Sets if the player can rotate the camera.
useEdgeBumpPanning Sets if the player can pan the camera by moving the mouse to the edge of the screen.
pitch Controls default pitch angle of the camera.
roll Controls default roll angle of the camera.
yaw Controls default yawangle of the camera.
mode Sets whether the camera is locked to the player character or can be freely moved. Can only be set to *"Locked"* and *"Free"*.

Limitations

While this camera was designed to work with default Roblox controls, there are two cases where it will not.

First, this camera conflicts with the zoom occlusion mode. This camera is designed to stay at a fixed height, whereas zoom occlusion zooms in whenever a part is in the way of the camera. This causes a conflict resulting in an unusable camera. To make sure this doesn’t happen, set StarterPlayer/DevCameraOcclusionMode to Invisicam.

Secondly, this camera conflicts with gamepad controls when in “Free” mode. The gamepad uses the left thumbstick to control character movement, while this camera uses the left thumbstick to pan the camera. If designing a game to be compatible with gamepads, it is recommended to either leave the camera in “Locked” mode or to modify either the camera or the movement controls so that one or the other uses a different input than the left thumbstick.

Tags:
  • camera
  • gameplay
  • coding