UIStrokeAdjuster - Properly scale your UIStrokes!

PROBLEM

Simply…UISTROKES ARE THE PROBLEM!

After its release, there have been many complications using UIStroke in projects. UIStrokes don’t properly scale relative to screen resolution (devices) nor relative to camera distance on BillboardGuis. To counter this issue, I created the “UIStrokeAdjuster” module!

BEFORE UIStrokeAdjuster


AFTER UIStrokeAdjuster


SOLUTION

Features

  • Effectively uses CollectionService
  • Automatically recursively tags ScreenGuis and BillboardGuis in PlayerGui
  • Extra configurations to best fit your use case
  • Automatic cleanup and garbage collection

Drag, Drop, Require, Done!

  1. Get UIStrokeAdjuster HERE

  2. Create a directory similar to this (if preferred):
    image

  3. Open the module, read, and adjust these configurations:

  4. Simply require the UIStrokeAdjuster from the local script:
    local UIStrokeAdjuster = require(script.UIStrokeAdjuster)

  5. Use these methods to update selected ScreenGuis and BillboardGuis
    :TagScreenGui(screenGui: ScreenGui)
    :TagBillboardGui(billboardGui: BillboardGui)

  6. YOU’RE DONE


SETTING UP BILLBOARDS

Don’t worry if these steps are too difficult or tedious, the module WILL GUESS if you don’t follow these steps, though I’d advise doing these for optimal visuals.

  1. In studio, set the adornee/parent of the BillboardGui to a part.
  2. Select the part and ONLY the part using your mouse.
  3. Move the Camera to an optimal distance, and calculate the magnitude between the Camera and the part by pasting this into the command bar and pressing enter. View the output print((game:GetService("Selection"):Get()[1].Position - workspace.Camera.CFrame.Position).Magnitude)
  4. Set the “Distance” attribute of the BillboardGui to this magnitude:

CONFIGURATIONS and USE CASES

The module automatically tags current and new BillboardGuis and ScreenGuis parented to PlayerGui. This is an option you can toggle in the module’s configurations. However, you can also tag BillboardGuis and ScreenGuis that aren’t parented to PlayerGui: UIStrokeAdjuster:TagScreenGui(screenGui: ScreenGui)
UIStrokeAdjuster:TagBillboardGui(billboardGui: BillboardGui)


CLOSING
I hope this utility will make the best of your project. I will accept all criticism and suggestions. Thank you! :slight_smile:

SOURCE CODE
--[=[
	Awesom3_Eric
	12/24/2024 @ 12:01AM
	@module UIStrokesAdjuster
		Adjusts the UIStrokes of GuiObjects based on viewport size and distance from BillboardGuis
]=]

--!strict



-----[[ Configuration ]]-----

-- Paste the following code into the command bar and change the Studio_Viewport_Size to the values in the output bar:
-- print(workspace.CurrentCamera.ViewportSize)
local Studio_Viewport_Size = Vector2.new(1616, 880)

-- Set to true to automatically tag BillboardGuis and ScreenGuis recursively in PlayerGui
-- It's advised to use the :TagScreenGui() and :TagBillboardGui() features for the best performance
local Auto_Tag = false

-- Stroke sizes update every Update_Delay seconds
local Update_Delay = 1

-- Change if you want
local Billboard_Tag = "Billboard"
local Screen_Gui_Tag = "ScreenGui"
local UIStroke_Tag = "UIStroke"
local Screen_Stroke_Tag = "ScreenStroke"
local Default_Billboard_Distance = 10 -- Estimated distance if "Distance" attribute of BillboardGui is not set





-----[[ Initialization ]]-----

-- Services
local CollectionService = game:GetService("CollectionService")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

-- Player
local player = Players.LocalPlayer
local PlayerGui = player:WaitForChild("PlayerGui", 100)

-- Variables
local camera = workspace.CurrentCamera





-----[[ Utility Functions ]]-----

--[=[
	Returns average resolution of Vector2
		@param vector2: Vector2
		@return min: number
]=]
local function getBox(vector2: Vector2): number
	return math.min(vector2.X, vector2.Y)
end

--[=[
	Returns ratio of current viewport size to studio viewport size
		@return viewportSize: number
]=]
local function getScreenRatio(): number
	return getBox(camera.ViewportSize)/getBox(Studio_Viewport_Size)
end

--[=[
	Recursively tags instance with tag based on objectType
	Listens for added instances
		@param instance: Instance
		@param objectType: string
		@param tag: string
]=]
local function tagRecursive(instance: Instance, objectType: string, tag: string)
	if instance:IsA(objectType) then
		instance:AddTag(tag)
	end
	for _, child in instance:GetChildren() do
		tagRecursive(child, objectType, tag)
	end
	instance.ChildAdded:Connect(function(child)
		tagRecursive(child, objectType, tag)
	end)
end

--[=[
	Returns position of part or model (for relative BillboardGui position)
		@param instance: Instance
		@return position: Vector3
]=]
local function getInstancePosition(instance: Instance): Vector3
	if instance:IsA("Part") then
		return instance.Position
	elseif instance:IsA("Model") then
		return instance:GetPivot().Position
	end
	return Vector3.new(0, 0, 0)
end





-----[[ ScreenGui Updating ]]-----

-- Set OriginalThickness attribute
local function initTaggedUIStroke(uiStroke: UIStroke)
	-- Check if tagged instance is a UIStroke
	if not uiStroke:IsA("UIStroke") then
		uiStroke:RemoveTag(Screen_Stroke_Tag)
		uiStroke:RemoveTag(UIStroke_Tag)
		return
	end

	-- Initialize OriginalThickness
	if not uiStroke:GetAttribute("OriginalThickness") then
		uiStroke:SetAttribute("OriginalThickness", uiStroke.Thickness)
	end

	-- Initialize if has screen tag
	if uiStroke:HasTag(Screen_Stroke_Tag) then
		uiStroke.Thickness *= getScreenRatio()
	end
end

-- Initialize tagged UI Strokes
for _, uiStroke: UIStroke in CollectionService:GetTagged(UIStroke_Tag) do
	initTaggedUIStroke(uiStroke)
end

-- Listen for tagged UI Strokes
CollectionService:GetInstanceAddedSignal(UIStroke_Tag):Connect(initTaggedUIStroke)


-- Initialize currently tagged UIStrokes
for _, uiStroke: UIStroke in CollectionService:GetTagged(Screen_Stroke_Tag) do
	uiStroke:AddTag("UIStroke")
end

-- Indexes UIStroke in ScreenStrokes and its original thickness to update
CollectionService:GetInstanceAddedSignal(Screen_Stroke_Tag):Connect(function(uiStroke: UIStroke)
	uiStroke:AddTag("UIStroke")
end)


-- Recurisvely tags UIStrokes in ScreenGui that are currently tagged
for _, screenGui in CollectionService:GetTagged(Screen_Gui_Tag) do
	if not screenGui:IsA("ScreenGui") then
		screenGui:RemoveTag(Screen_Gui_Tag)
		continue
	end
	tagRecursive(screenGui, "UIStroke", Screen_Stroke_Tag)
end

-- Listen for new instances that are tagged
CollectionService:GetInstanceAddedSignal(Screen_Gui_Tag):Connect(function(screenGui: ScreenGui)
	if not screenGui:IsA("ScreenGui") then
		return
	end
	tagRecursive(screenGui, "UIStroke", Screen_Stroke_Tag)
end)


-- Updates ScreenGui strokes
local function updateScreenGuiStrokes()
	for _, uiStroke: UIStroke in CollectionService:GetTagged(Screen_Stroke_Tag) do
		local originalThickness = uiStroke:GetAttribute("OriginalThickness") :: number
		if originalThickness then
			uiStroke.Thickness = originalThickness * getScreenRatio()
		end
	end
end

-- Updates UIStrokes thickness in ScreenStrokes when camera viewport size changes
camera:GetPropertyChangedSignal("ViewportSize"):Connect(updateScreenGuiStrokes)





-----[[ BillboardGui Updating ]]-----

-- This dictionary keeps track of the UIStrokes that are children of a billboard gui
-- Used to iterate and update UIStrokes based on distances
local BillboardData: {[BillboardGui]: {UIStroke}} = {}

-- Recurisvely tag ui strokes and append to BillboardData
local function recurseGetUIStrokes(instance: Instance, billboardGui: BillboardGui)
	if instance:IsA("UIStroke") then
		instance:AddTag("UIStroke")
		table.insert(BillboardData[billboardGui], instance)
	end
	for _, child in instance:GetChildren() do
		recurseGetUIStrokes(child, billboardGui)
	end
	instance.ChildAdded:Connect(function(child: Instance)
		recurseGetUIStrokes(child, billboardGui)
	end)
end

-- Initialize billboard
local function initBillboard(billboardGui: BillboardGui)	
	-- Remove tag is not billboard
	if not billboardGui:IsA("BillboardGui") then
		billboardGui:RemoveTag(Billboard_Tag)
		return
	end

	-- Create billboard
	-- Add clean function
	BillboardData[billboardGui] = {}
	billboardGui.Destroying:Once(function()
		BillboardData[billboardGui] = nil
	end)

	-- Get UIStrokes from Billboard recursively
	recurseGetUIStrokes(billboardGui, billboardGui)
end

-- Tag billboard guis
for _, billboardGui: BillboardGui in CollectionService:GetTagged(Billboard_Tag) do
	initBillboard(billboardGui)
end

-- Listen for tagged billboards
CollectionService:GetInstanceAddedSignal(Billboard_Tag):Connect(initBillboard)



-- Update UIStrokes every Update_Delay seconds
local start = tick()
local update; update = RunService.Heartbeat:Connect(function()
	if tick() - start < Update_Delay then 
		return 
	end
	start = tick()

	-- Update
	for billboardGui, uiStrokes in BillboardData do
		local adornee = billboardGui.Adornee
		local origin: Vector3

		-- Check adornee or parent
		if adornee then
			origin = getInstancePosition(adornee)
		else
			if billboardGui.Parent then
				origin = getInstancePosition(billboardGui.Parent)
			end
		end

		-- If no origin is defined, don't update
		if not origin then
			continue
		end

		-- Check if camera is within range
		local magnitude = (camera.CFrame.Position - origin).Magnitude
		local maxDistance = billboardGui.MaxDistance
		if magnitude > maxDistance then
			continue
		end
		
		-- Update BillboardStrokes
		local distanceRatio = ((billboardGui:GetAttribute("Distance") or Default_Billboard_Distance)/magnitude)
		for _, uiStroke in uiStrokes do
			if not uiStroke:IsDescendantOf(billboardGui) then
				table.remove(uiStrokes, table.find(uiStrokes, uiStroke))
			end
			
			local originalThickness = uiStroke:GetAttribute("OriginalThickness") :: number
			if originalThickness then
				uiStroke.Thickness = originalThickness * distanceRatio * getScreenRatio()
			end
		end
	end
end)





-----[[ Auto_Tag ]]-----

-- Automatically tag ScreenGuis and BillboardGuis in PlayerGui if Auto_Tag == true
if Auto_Tag then
	tagRecursive(PlayerGui, "ScreenGui", Screen_Gui_Tag)
	tagRecursive(PlayerGui, "BillboardGui", Billboard_Tag)
end





-----[[  Module Functions ]]-----

type UIStrokeAdjuster = {
	TagScreenGui: (self: UIStrokeAdjuster, screenGui: ScreenGui) -> (),
	TagBillboardGui: (self: UIStrokeAdjuster, billboardGui: BillboardGui) -> (),
}
type _UIStrokeAdjuster = UIStrokeAdjuster & {

}
local UIStrokeAdjuster = {} :: any

--[=[
	Tags ScreenGui to apply UIStrokes update
		@param screenGui: ScreenGui
]=]
function UIStrokeAdjuster.TagScreenGui(self: _UIStrokeAdjuster, screenGui: ScreenGui)
	if screenGui:IsA("ScreenGui") then
		CollectionService:AddTag(screenGui, Screen_Gui_Tag)
	end
end

--[=[
	Tags ScreenGui to apply UIStrokes update
		@param billboardGui: BillboardGui
]=]
function UIStrokeAdjuster.TagBillboardGui(self: _UIStrokeAdjuster, billboardGui: BillboardGui)
	if billboardGui:IsA("BillboardGui") then
		CollectionService:AddTag(billboardGui, Billboard_Tag)
	end
end

return UIStrokeAdjuster :: UIStrokeAdjuster

** UPDATE 12/25/2024

213 Likes

Exactly 2 hours ago I was looking for a tool to do this since I couldn’t figure it out myself… Thank you for providing this resource!!

This works extremely well!

just so happens that you posted this right as I re-checked the forum…

8 Likes

Much cleaner than my implementation!

3 Likes

Seems extremely useful, definitely will use the next time when I decide to make UI

4 Likes

keep in mind uistrokes are expensive in performance, and changing the uistroke thickness at camera position change for example could potentially lag a ton, as it did for my game.

7 Likes

Hey pug!! It’s great to see you here :slight_smile: you’re definitely right! I will modify my module with a more optimized way of scaling the strokes with less updates

2 Likes

I notice you are using the average screen size to calculate stroke thickness, which can give inaccurate results with unconventional aspect ratios (like ultrawide screens).

Instead, I recommend finding the maximum square size, similar to how ImageLabels have Fit scale type.

local function getBox(vector: Vector2): number
	return math.min(vector.X, vector.Y)
end
7 Likes

Hey OutOfDinos! Thanks so much for the feedback, you’re completely right, the average function I used does not return an accurate “average” when scaling one axis of my screen, so I will implement this ASAP :slight_smile:

4 Likes

I was getting super paranoid figuring out how to scale the uistrokes. I was very close to scripting a system myself, I am very thankfull that i found this. What a wonderfull system! Saved me a bunch of time!!

2 Likes

Why isn’t this already a feature :confused:

13 Likes

Thanks so much! This works great.

3 Likes

I would advise against using this module in its current state as it can cause major performance issues in your game (as it did for me). I can not talk for the Billboard side of things as I did not use it for BillboardGuis, but the code for ScreenGui-based UIStrokes can cause big performance hits.

However, there are a few small changes you can make to the module code / your implementation to make it more performant and suitable for projects…

  1. If you have any form of “UI particles”, e.g. when clicking a button or for adding coin effects, do NOT use the Auto_Tag feature (which is enabled by default). Instead, use the module:TagScreenGui() function in the script where you require the module to tag the ScreenGuis that you actually want to scale the descendants of, and do NOT tag the ScreenGui where your particles are added.

    I found that adding many particles at the same time caused a ton of lag because of this module’s tagRecursive() function running every time a new descendant was added.

  2. Updating the Thickness property of the UIScales every Heartbeat is unnecessary and causes performance issues - Roblox even advises against it on the UIStroke API Reference page. To make the scales adapt to the screen size, update the sizes whenever the window size changes rather than every Heartbeat.

    The change is easy:
    Change RunService.Heartbeat
    to workspace.CurrentCamera:GetPropertyChangedSignal("ViewportSize")

local function updateScreenStrokes()
	for uiStroke, originalThickness in ScreenStrokes do
		if not uiStroke.Parent then
			ScreenStrokes[uiStroke] = nil
		else
			uiStroke.Thickness = originalThickness * getScreenRatio()
		end
	end
end

-- Update UIStroke thickness in ScreenStrokes whenever the screen size changes
workspace.CurrentCamera:GetPropertyChangedSignal("ViewportSize"):Connect(updateScreenStrokes)

The idea and functionality of this module is great, but I wouldn’t recommend using it without editing the code yourself to accommodate these performance improvements. Hopefully the creator will update the module to support these changes.

12 Likes

UIStrokeAdjuster:TagBillboardGui(BillboardGui) - Use this

1 Like

UPDATE - PERFORMANCE UPGRADE

More accurate calculations of UIStroke sizes for varying aspect ratios using maximum square space.

New configuration called Update_Delay, which updates strokes in BillboardGuis every Update_Delay seconds.

The Auto_Tag feature is now disabled by default.

Developers will now be forced to use these methods by default.
:TagScreenGui(screenGui: ScreenGui)
:TagBillboardGui(billboardGui: BillboardGui)

Stroke sizes update every change in the Camera viewport size.

4 Likes

Thank you for the suggestions, most of this has been implemented in the new update! I appreciate it a lot :slight_smile:

3 Likes

Hey!

This module UIStrokeAdjuster looks great, I’ve used it in my game for a little while now. There is no problem with the module, except for the specific problem I’ve faced during usage of the module UIStrokeAdjuster.

The farther the camera is, the less noticeable the TextLabel stroke is. The scaling is pretty consitant (good job!). However, it only gets less noticeable, is it possible for us to manually adjust the thickness at certain camera distance (or done automatically if possible) just like in Pet Simulator 99, all the TextLabel in billboard is scaled relative to the camera distance but also is adjusted depending on how far the camera goes.

Roblox already does this, it’s just that it does too much. I wish to only adjust the thickness at certain distance and not higher.

Regardless, this module UIStrokeAdjuster is pretty good! I’d recommend it for anyone reading here!

/Mub

2 Likes

You mean like a half scaler? do something like half the camera distance variable where the stroke size is set

1 Like

No, I mean like a fixed thickness scale for certain distance, (look carefully at the billboards in PS99) the scaling stays same when you are moving closer or far but when you move very far, the thickness should be set to fixed bigger one.

Basically, switch from static scaling (staying same no matter where you go) to automatic scaling (based on the certain distance).

For example, if the distance between you and the billboard is like ~3 then the billboard would be set to 5.

If you go far or closer, the scaling would be same anyway but if you go like above ~10, the billboard thickness shouldn’t scale the same and instead would be set to 8 (and will stay that way until you get close enough).

All of these are done in PS99

3 Likes

hey, i know that this is pretty old, but it’s not working for me? It does not scale billboardGui’s on my side, and i have done every step. the adornee is set and everything

image

1 Like

You could rewrite the script yourself. Insert the billboard guis into a table and loop through them every few frames or something, if at those distances then set the thickness of all descendant uistrokes

1 Like