BillboardUI that points at direction when object is not visible?

This is what I mean

Skip to 4:07

This is from @crusherfire,
How did he do this?

I was able to do something similar using normal ScreenGui

How do you calculate the anchor point? I also need to rotate it towards the part too.

I’ll break it down for you!

I made a wrapper class for BillboardGui’s that gives me extra features, such as accessing what the current rendered position of the billboard is in the world. I use this position to calculate where an element should be on screen so I can smoothly transition from a BillboardGui to a ScreenGui. Unfortunately, there is no API to determine what the position of a billboard is in the world, so we have to calculate it ourselves.

I have a function that does this:

-- Returns the world position that represents the anchor point for the BillboardGui.
-- This calculation accurately takes into account the billboard's extents and studs offset properties.
function Module.getBillboardWorldPosition(billboardGui: BillboardGui, camera: Camera?): Vector3?
	local adornee: Attachment | BasePart | Model = (billboardGui.Adornee or billboardGui.Parent) :: any
	if not adornee then
		return
	end

	local adorneeCFrame
	if adornee:IsA("Attachment") then
		adorneeCFrame = adornee.WorldCFrame
	elseif adornee:IsA("BasePart") then
		adorneeCFrame = adornee.CFrame
	elseif adornee:IsA("Model") then
		-- For a model, Roblox uses bounding-box CFrame instead of :GetPivot()
		local cf, _ = adornee:GetBoundingBox()
		adorneeCFrame = cf
	else
		return
	end

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

	local finalPos = adorneeCFrame.Position

	-- 'StudsOffsetWorldSpace' is a bit misleading because the the offset is applied based on the adornee's axes and not the world axes.
	-- But technically the Vector offset is in world space.
	finalPos = finalPos + adorneeCFrame:VectorToWorldSpace(billboardGui.StudsOffsetWorldSpace)

	-- Offset based on camera's orientation (excluding position since the camera is not the adornee).
	local camCFrame = camera.CFrame
	local camRot = camCFrame - camCFrame.Position
	local studsOffset = billboardGui.StudsOffset
	local studsOffsetWorld = camRot:VectorToWorldSpace(studsOffset)
	finalPos = finalPos + studsOffsetWorld

	-- Extents properties are ignored for attachments!
	if not adornee:IsA("Attachment") then
		-- ExtentsOffsetWorldSpace computation --
		if billboardGui.ExtentsOffsetWorldSpace ~= Vector3.zero then
			local offset = billboardGui.ExtentsOffsetWorldSpace

			-- Get the bounding-box size in world axes
			local cframe, sizeWS = Module.getBoundingBox(adornee)
			local halfSizeWS = sizeWS * 0.5

			-- Multiply per-axis
			local offset = Vector3.new(
				halfSizeWS.X * offset.X,
				halfSizeWS.Y * offset.Y,
				halfSizeWS.Z * offset.Z
			)

			offset = cframe:VectorToWorldSpace(offset)
			finalPos += offset
		end
		
		-- ExtentsOffset computation --
		if billboardGui.ExtentsOffset ~= Vector3.zero then
			local offset = billboardGui.ExtentsOffset

			-- Get camera-aligned bounding box size
			local _, sizeCam = Module.getCameraAlignedBoundingBox(adornee, camera)
			local halfSizeCam = sizeCam * 0.5

			-- scale the offset by half the camera's bounding box
			local offsetCamSpace = Vector3.new(
				halfSizeCam.X * offset.X,
				halfSizeCam.Y * offset.Y,
				halfSizeCam.Z * offset.Z
			)

			-- rotate from camera space -> world space
			-- We'll get pure rotation from the camera by subtracting translation:
			local camCFrame = camera.CFrame
			local camRotOnly = camCFrame - camCFrame.Position

			offset = camRotOnly:VectorToWorldSpace(offsetCamSpace)
			finalPos += offset
		end
	end

	return finalPos
end

Since some of the the properties of a BillboardGui is based on the ‘camera-aligned bounding box’, we also need to calculate this. I couldn’t find much information on this in Roblox docs, but surprisingly, ChatGPT was able to help me quite a bit to create a refined function that calculates a camera-aligned bounding box for a model/part:

-- Calculates the bounding box that perfectly encapsulates the part/model aligned with the camera's axes.
-- <strong>camera</strong>: Defaults to workspace.CurrentCamera if not provided.
function Module.getCameraAlignedBoundingBox(instance: BasePart | Model, camera: Camera?): (CFrame, Vector3)
	local camera = camera or workspace.CurrentCamera
	if not camera then
		return CFrame.identity, Vector3.zero
	end

	local baseCFrame, size do
		if instance:IsA("BasePart") then
			baseCFrame, size = instance.CFrame, instance.Size
		elseif instance:IsA("Model") then
			baseCFrame, size = instance:GetBoundingBox()
		else
			return CFrame.identity, Vector3.zero
		end
	end

	local halfSize = size * 0.5
	local corners = {
		Vector3.new(-halfSize.X, -halfSize.Y, -halfSize.Z),
		Vector3.new(-halfSize.X, -halfSize.Y,  halfSize.Z),
		Vector3.new(-halfSize.X,  halfSize.Y, -halfSize.Z),
		Vector3.new(-halfSize.X,  halfSize.Y,  halfSize.Z),
		Vector3.new( halfSize.X, -halfSize.Y, -halfSize.Z),
		Vector3.new( halfSize.X, -halfSize.Y,  halfSize.Z),
		Vector3.new( halfSize.X,  halfSize.Y, -halfSize.Z),
		Vector3.new( halfSize.X,  halfSize.Y,  halfSize.Z),
	}

	local camCF = camera.CFrame
	local minX, minY, minZ = math.huge, math.huge, math.huge
	local maxX, maxY, maxZ = -math.huge, -math.huge, -math.huge

	for _, cornerLocal in ipairs(corners) do
		local cornerWorld = baseCFrame:PointToWorldSpace(cornerLocal)
		local cornerCam   = camCF:PointToObjectSpace(cornerWorld)

		if cornerCam.X < minX then minX = cornerCam.X end
		if cornerCam.Y < minY then minY = cornerCam.Y end
		if cornerCam.Z < minZ then minZ = cornerCam.Z end

		if cornerCam.X > maxX then maxX = cornerCam.X end
		if cornerCam.Y > maxY then maxY = cornerCam.Y end
		if cornerCam.Z > maxZ then maxZ = cornerCam.Z end
	end

	local minCam = Vector3.new(minX, minY, minZ)
	local maxCam = Vector3.new(maxX, maxY, maxZ)
	local centerCam = (minCam + maxCam) * 0.5
	local extentCam = maxCam - minCam

	local centerWorld = camCF:PointToWorldSpace(centerCam)
	local rotationOnly = camCF - camCF.Position

	local boundingBoxCF = rotationOnly + centerWorld

	return boundingBoxCF, extentCam
end

Calculating this allows us to get an accurate world position for the BillboardGui. Then I take this position and calculate where it would be clamped onto the screen as a ScreenGui element via this function:

-- Takes <code>worldPos</code> and converts it to a screen position that is clamped along the screen's edges if <code>worldPos</code> is out of the camera's view.
-- <strong>padding</strong>: Optional padding for calculating the clamped screen position.
-- <strong>camera</strong>: Default camera is <code>workspace.CurrentCamera</code>
-- Returns the clamped screen position & a boolean indicating if the position was clamped.
function Module.toClampedScreenSpace(worldPos: Vector3, padding: Vector2?, camera: Camera?): (Vector2, boolean)
	local camera = camera or workspace.CurrentCamera
	local padding = padding or Vector2.zero
	
	local viewportSize = camera.ViewportSize
	local screenCenter = Vector2.new(viewportSize.X/2, viewportSize.Y/2)
	local viewPos, onScreen = camera:WorldToViewportPoint(worldPos)
	
	local screenPos = Vector2.new(viewPos.X, viewPos.Y)
	local direction = (screenPos - screenCenter)
	
	local wasBehind = viewPos.Z < 0
	if wasBehind then
		direction = -direction
	end
	
	local maxX = viewportSize.X - padding.X
	local maxY = viewportSize.Y - padding.Y

	-- Proposed position, relative to center
	local proposed = screenCenter + direction

	-- Then clamp
	local clampedX = math.clamp(proposed.X, padding.X, maxX)
	local clampedY = math.clamp(proposed.Y, padding.Y, maxY)
	
	local wasXClamped = (clampedX ~= proposed.X)
	local wasYClamped = (clampedY ~= proposed.Y)
	local wasClamped = wasXClamped or wasYClamped or wasBehind
	
	if (wasBehind) and (not wasXClamped and not wasYClamped) and direction.Magnitude > 0 then
		-- Calculate how far we can go in X or Y before hitting the boundary
		local scaleX, scaleY

		if direction.X > 0 then
			scaleX = (maxX - screenCenter.X) / direction.X
		else
			scaleX = (padding.X - screenCenter.X) / direction.X
		end

		if direction.Y > 0 then
			scaleY = (maxY - screenCenter.Y) / direction.Y
		else
			scaleY = (padding.Y - screenCenter.Y) / direction.Y
		end

		-- Pick the smaller absolute scale so that we hit an edge on X or Y
		local scale = math.min(math.abs(scaleX), math.abs(scaleY))
		direction = direction * scale

		-- Recompute proposed and clamp again
		proposed = screenCenter + direction
		clampedX = math.clamp(proposed.X, padding.X, maxX)
		clampedY = math.clamp(proposed.Y, padding.Y, maxY)
	end
	
	return Vector2.new(clampedX, clampedY), wasClamped
end

So, every frame in my wrapped class, I calculate the following below for my wrapped billboards. There’s a funky bug with rendering frames (not sure if it has been fixed yet) described in the linked devforum post in the comment I was experiencing, and it fixes itself by indexing for AbsolutePosition lol

local framePos, wasClamped = FunctionUtils.Camera.toClampedScreenSpace(
    self:GetBillboardPosition(),
    Vector2.new(stayFrame.Size.X.Offset/2, stayFrame.Size.Y.Offset/2)
)

if wasClamped then
    stayFrame.Position = UDim2.fromOffset(framePos.X, framePos.Y)
    -- funky bug (roblox moment): --
    -- https://devforum.roblox.com/t/frames-are-rendered-at-previous-position-when-visibility-is-enabled/2930768
    local _ = stayFrame.AbsolutePosition
    -- --
    stayFrame.Visible = true
    billboard.Enabled = false
else
    billboard.Enabled = true
    stayFrame.Visible = false
end

It’s a lot of code for a small feature but it looks cool at least :melting_face:

1 Like

Wow, thanks for taking the time to write all that out! This will be a big help for me. Big fan, by the way!

1 Like

Using two different instances for this kind of effect gets quite finicky. There is a solution using only one instance parented to a ScreenGui.

Essentially, there are two different states. One where the location is within the viewport, and one where it isn’t.

local screenPoint, bounded = Camera:WorldToViewportPoint(TargetPart.Position)
local Follow = --> the gui element
if bounded then --> in viewport
	
else --> not in viewport

end

If it’s bounded by the viewport:

Follow.Position = UDim2.fromOffset(screenPoint.X, screenPoint.Y)

If it isn’t within the camera’s viewport, there are two cases as well. The first case is if it’s in section A (in front and beside the camera). The second case is if it’s in section B (anywhere behind the camera):

image

local inFront = Camera.CFrame.LookVector:Dot(TargetPart.Position - Camera.CFrame.Position) > 0
if inFront then --> in front

else --> at the back

end

If it’s in section A, we clamp it to the bounds of the camera

Follow.Position = UDim2.fromOffset(
	math.clamp(screenPoint.X, 0, Camera.ViewportSize.X),
	math.clamp(screenPoint.Y, 0, Camera.ViewportSize.Y)
)

If it’s in section B, we clamp the Y axis to the top and bottom (depending on whether the object is higher/lower than the camera). You clamp the x position to the bounds of the camera:

Follow.Position = UDim2.fromOffset(
	math.clamp(Camera.ViewportSize.X - screenPoint.X, 0, Camera.ViewportSize.X),
	screenPoint.Y > Camera.ViewportSize.Y / 2 and 0 or Camera.ViewportSize.Y
)

Here’s the test code combining all of these cases:

--!strict

----------------------------------------------------------------------------------------------------
local RunService = game:GetService("RunService")
----------------------------------------------------------------------------------------------------
local Camera = workspace.Camera
local TargetPart = workspace.Target

local Follow = script.Parent.Frame
RunService.Heartbeat:Connect(function()
	local screenPoint, bounded = Camera:WorldToViewportPoint(TargetPart.Position)

	if bounded then
		Follow.Position = UDim2.fromOffset(screenPoint.X, screenPoint.Y)
	else
		local inFront = Camera.CFrame.LookVector:Dot(TargetPart.Position - Camera.CFrame.Position) > 0

		if inFront then
			Follow.Position = UDim2.fromOffset(
				math.clamp(screenPoint.X, 0, Camera.ViewportSize.X),
				math.clamp(screenPoint.Y, 0, Camera.ViewportSize.Y)
			)
		else --> directly behind
			Follow.Position = UDim2.fromOffset(
				math.clamp(Camera.ViewportSize.X - screenPoint.X, 0, Camera.ViewportSize.X),
				screenPoint.Y > Camera.ViewportSize.Y / 2 and 0 or Camera.ViewportSize.Y
			)
		end
	end
end)

Here’s a video demo:

File:
Follow Camera.rbxl (58.4 KB)