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
Wow, thanks for taking the time to write all that out! This will be a big help for me. Big fan, by the way!
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):
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)