The following is my attempt. I’ve tried to comment it as best I can but lmk if you have questions.
Video
Example Code
--[!] SERVICES
local RunService = game:GetService('RunService')
local PlayerService = game:GetService('Players')
--[!] CONST
local EPSILON = 1e-6
local XZ_VECTOR = Vector3.new(1, 0, 1)
local CAMERA_FOV = 10 -- desired camera field of view
local CAMERA_HEIGHT = 100 -- desired camera height
local CAMERA_ORIGIN = Vector3.zero -- origin point of the camera
local THRESHOLD_OFFSET = 6 -- how far we push the player over the other side when tping
local THRESHOLD_INTERSECT = 1 -- padding to intersect threshold
--[!] UTILS
--> essentially the same as calling `.Unit` on a vector
-- used to ensure we safely normalise & don't renormalise a vec
local function getNormalisedVector(vec)
if vec == Vector3.zero then
return vec
end
local d = vec.X*vec.X + vec.Y*vec.Y + vec.Z*vec.Z
if d > 1 then
return vec.Unit
end
return vec
end
--> computes a plane from 3 points
-- learn more about planes here, e.g. ref:
-- - https://gdbooks.gitbooks.io/3dcollisions/content/Chapter1/plane.html
-- - https://en.wikipedia.org/wiki/Plane_(mathematics)
--
local function planeFromPoints(a, b, c, output)
local normal = (b - a):Cross(c - a).Unit
local distance = -normal:Dot(a)
if not output then
output = table.create(2)
end
output[1] = normal
output[2] = distance
return output
end
--> used to compute the frustum of the camera
-- Learn more about viewing frustum e.g. ref @ https://en.wikipedia.org/wiki/Viewing_frustum
--
local function buildFrustumFromCamera(camera, farPlaneZ, output)
farPlaneZ = farPlaneZ or 200
local transform = camera.CFrame
local translation = transform.Position
local nearPlaneZ = camera.NearPlaneZ
local fieldOfView = camera.FieldOfView
local tanHalfFov = math.tan(math.rad(fieldOfView * 0.5))
local viewportSize = camera.ViewportSize
local viewportAspectRatio = viewportSize.X / viewportSize.Y
local farPlaneHeight_2 = tanHalfFov*farPlaneZ
local farPlaneWidth_2 = farPlaneHeight_2*viewportAspectRatio
local nearPlaneHeight_2 = tanHalfFov*-nearPlaneZ
local nearPlaneWidth_2 = nearPlaneHeight_2*viewportAspectRatio
farPlaneZ *= -1
local farTopLeft = transform * Vector3.new( -farPlaneWidth_2, farPlaneHeight_2, farPlaneZ)
local farTopRight = transform * Vector3.new( farPlaneWidth_2, farPlaneHeight_2, farPlaneZ)
local farBottomRight = transform * Vector3.new( farPlaneWidth_2, -farPlaneHeight_2, farPlaneZ)
local nearTopLeft = transform * Vector3.new(-nearPlaneWidth_2, nearPlaneHeight_2, nearPlaneZ)
local nearTopRight = transform * Vector3.new( nearPlaneWidth_2, nearPlaneHeight_2, nearPlaneZ)
local nearBottomLeft = transform * Vector3.new(-nearPlaneWidth_2, -nearPlaneHeight_2, nearPlaneZ)
local nearBottomRight = transform * Vector3.new( nearPlaneWidth_2, -nearPlaneHeight_2, nearPlaneZ)
output = output or { }
output.transform = transform
output.translation = translation
output.viewportSize = viewportSize
local frustumPlanes = output.planes
if not frustumPlanes then
frustumPlanes = table.create(6)
output.planes = frustumPlanes
end
frustumPlanes[1] = planeFromPoints( nearTopRight, nearBottomRight, nearTopLeft, frustumPlanes[1]) -- near
frustumPlanes[2] = planeFromPoints( farTopRight, farTopLeft, farBottomRight, frustumPlanes[2]) -- far
frustumPlanes[3] = planeFromPoints( nearTopRight, nearTopLeft, farTopRight, frustumPlanes[3]) -- top
frustumPlanes[4] = planeFromPoints(nearBottomRight, farBottomRight, nearBottomLeft, frustumPlanes[4]) -- bottom
frustumPlanes[5] = planeFromPoints( nearTopLeft, nearBottomLeft, farTopLeft, frustumPlanes[5]) -- left
frustumPlanes[6] = planeFromPoints( nearTopRight, farTopRight, nearBottomRight, frustumPlanes[6]) -- right
return output
end
--> used to check whether a player is alive
-- i.e. one that's alive and has both a humanoid + a root part
local function isAlive(character)
if typeof(character) ~= 'Instance' or not character:IsA('Model') or not character:IsDescendantOf(workspace) then
return false
end
local humanoid = character:FindFirstChildOfClass('Humanoid')
local humanoidState = humanoid and humanoid:GetState() or Enum.HumanoidStateType.Dead
local humanoidRootPart = humanoid and humanoid.RootPart or nil
if humanoidState == Enum.HumanoidStateType.Dead or not humanoidRootPart then
return false
end
return true
end
--> used to await a player's character
-- i.e. one that exists + is alive
local function tryGetCharacter(player)
if typeof(player) ~= 'Instance' or not player:IsA('Player') then
return nil
end
local character
while not character do
if not player or not player:IsDescendantOf(PlayerService) then
break
end
local char = player.Character
if not char then
player.CharacterAdded:Wait()
continue
end
if not isAlive(char) then
RunService.Stepped:Wait()
continue
end
character = char
end
return character
end
--> used to cleanup any connections/instances
local function cleanupDisposables(disposables)
for i = 1, #disposables, 1 do
local disposable = disposables[i]
local t = typeof(disposable)
if t == 'RBXScriptConnection' then
pcall(disposable.Disconnect, disposable)
elseif t == 'Instance' then
pcall(disposable.Destroy, disposable)
end
end
table.clear(disposables)
end
--[!] MAIN
local function beginTracking(player)
local character = tryGetCharacter(player)
if not character then
return
end
-- cleanup
local disposables = { }
-- set up camera
local camera = game.Workspace.CurrentCamera
local offset = (10 / math.clamp(CAMERA_FOV, 1, 90)) * CAMERA_HEIGHT
local cameraOrigin = CFrame.lookAt(CAMERA_ORIGIN + Vector3.one*offset, CAMERA_ORIGIN)
camera.CFrame = cameraOrigin
camera.CameraType = Enum.CameraType.Scriptable
camera.FieldOfView = CAMERA_FOV
camera.CameraSubject = nil
-- set up camera frustum
-- we're going to use the planes of the camera as "walls"
-- so that we can check to see if a player is inside/outside
-- those specific walls
local frustum = buildFrustumFromCamera(camera, offset)
local frustumPlanes = frustum.planes
-- set up character listener(s)
local humanoid = character:FindFirstChildOfClass('Humanoid')
local rootPart = humanoid.RootPart
local runtime
runtime = RunService.Stepped:Connect(function (gt, dt)
-- cleanup if we're dead
if not isAlive(character) then
return cleanupDisposables(disposables)
end
local transform = rootPart.CFrame
local translation = transform.Position
-- normalise the movementVector because this isn't
-- always normalised, e.g. when on a mobile device
local moveDirection = getNormalisedVector(humanoid.MoveDirection*XZ_VECTOR)
local moveMagnitude = moveDirection.Magnitude
local moveDistance
if moveMagnitude > EPSILON then
local speed = humanoid.WalkSpeed*dt
moveDistance = moveMagnitude*speed
end
local state = humanoid:GetState()
local isJumping = state == Enum.HumanoidStateType.Jumping or state == Enum.HumanoidStateType.Freefall
-- we only care if they intersect the top/left/bottom/right
-- planes of the frustum, so we're offseting to avoid checking
-- the near and far planes
local plane, isInside, planeNormal, planeDistance, pointDistance
for i = 3, 6, 1 do
-- ignore top/bottom surface if we're jumping
-- so we don't teleport when jumping into the
-- top and bottom surfaces
if isJumping and (i == 3 or i == 4) then
continue
end
plane = frustumPlanes[i]
planeNormal, planeDistance = plane[1], plane[2]
-- check to see which side of the plane we're on
pointDistance = planeNormal:Dot(translation) + planeDistance
isInside = pointDistance > 0
-- i.e. check to see whether we're:
-- a) already outside
-- OR; b) are about to step outside
--
local point
if not isInside or (moveDistance and pointDistance <= moveDistance + THRESHOLD_INTERSECT) then
-- get the closest point on the current plane
point = translation - planeNormal*pointDistance
end
-- ignore if neither condition is met
if not point then
continue
end
-- teleport the player to the other side by first getting the
-- opposite plane, then positioning them relative to the new
-- plane +/- the desired offset
local indexOffset = (i - 1) % 2
indexOffset = indexOffset == 0 and 1 or -1
plane = frustumPlanes[i + indexOffset]
planeNormal = plane[1]
planeDistance = plane[2]
-- get the closest point on the opposite plane
local d = planeNormal:Dot(point) + planeDistance
point = point - planeNormal*d
-- there's probably an easier way to do this but this is
-- all i can think of right now ...
--
-- i'm just using this to find the lookvector of the plane
-- from the perspective of the camera, so that we can
-- project the point back to the character's original
-- height
local r = camera:WorldToViewportPoint(point)
r = camera:ViewportPointToRay(r.X, r.Y, 0)
-- what we're essentially doing here is treating the
-- character's upVector and its height from the origin
-- as a plane, and then getting the intersection point
-- from the ray and the pseudo-character plane
local upVector = transform.UpVector
d = r.Direction:Dot(upVector)
d = -(frustum.translation:Dot(upVector) - translation.Y) / d
-- compute the final translation at the character's height
-- and offset the X/Z coords by the threshold we defined earlier
point = frustum.translation + r.Direction*d
point = point + planeNormal*XZ_VECTOR*THRESHOLD_OFFSET
-- finally, move our character
character:MoveTo(point)
break
end
end)
table.insert(disposables, runtime)
end
--[!] INIT
local player = PlayerService.LocalPlayer
if typeof(player.Character) ~= 'nil' then
beginTracking(player)
end
player.CharacterAdded:Connect(function ()
return beginTracking(player)
end)
The idea being that we can treat the top/right/bottom/left planes of the frustum as ‘walls’, and when a player crosses that wall, we can get the opposite plane and teleport them there.
e.g. where the orange part in this picture is the camera, and the lines coming from the block are the surfaces of the frustum: