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. Move the Camera to an optimal distance, and calculate the magnitude between the Camera and the part. i.e.: print((workspace.Part.Position - workspace.CurrentCamera.CFrame.Position).Magnitude)
  3. 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
--// 1/25/2024 @ 12:01AM
--// UIStrokeAdjuster



--[[== CONFIGURATIONS ==]]--

-- 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 lol
local Billboard_Tag = "Billboard"
local Screen_Gui_Tag = "ScreenGui"
local Screen_Stroke_Tag = "ScreenStroke"
local Default_Billboard_Distance = 10 -- Estimated distance if "Distance" attribute of BillboardGui is not set









--[[== CODE ==]]--

--|| Initialization ||--

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

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

-- Variables
local camera = workspace.CurrentCamera





--|| Utility Functions ||--

-- Returns average resolution of Vector2
local function getBox(vector: Vector2): number
	return math.min(vector.X, vector.Y)
end

-- Returns ratio of current viewport size to studio viewport size
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
local function tagRecursive(instance: Instance, objectType: string, tag: string)
	if instance:IsA(objectType) then
		CollectionService:AddTag(instance, 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)
local function getInstancePosition(instance: Instance): Vector3
	if instance:IsA("Part") then
		return instance.Position
	elseif instance:IsA("Model") then
		local cf, size = instance:GetBoundingBox()
		return cf.Position
	end
	return Vector3.new(0, 0, 0)
end





--|| ScreenGui Updating ||--

local ScreenStrokes = {}

-- Recurisvely tags UIStrokes in ScreenGui
CollectionService:GetInstanceAddedSignal(Screen_Gui_Tag):Connect(function(screenGui: ScreenGui)
	tagRecursive(screenGui, "UIStroke", Screen_Stroke_Tag)
end)

-- Indexes UIStroke in ScreenStrokes to update
CollectionService:GetInstanceAddedSignal(Screen_Stroke_Tag):Connect(function(uiStroke: UIStroke)
	ScreenStrokes[uiStroke] = uiStroke.Thickness
	uiStroke.Thickness *= getScreenRatio()
end)

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

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





--|| BillboardGui Updating ||--

-- Initializes thickness update on BillboardGui's UIStrokes
CollectionService:GetInstanceAddedSignal(Billboard_Tag):Connect(function(billboardGui: BillboardGui)
	-- Index BillboardGui's UIStrokes recursively
	local BillboardStrokes = {}
	local function getUiStrokeFromInstance(instance: Instance)
		if instance:IsA("UIStroke") then
			BillboardStrokes[instance] = instance.Thickness
		end
		for _, uiStroke in instance:GetChildren() do
			getUiStrokeFromInstance(uiStroke)
		end
		instance.ChildAdded:Connect(getUiStrokeFromInstance)
	end
	getUiStrokeFromInstance(billboardGui)
	
	-- 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()
		
		-- Disconnect if BillboardGui is deleted
		if not billboardGui.Parent then
			update:Disconnect()
		else
		-- Update
			local adornee = billboardGui.Adornee
			local origin = adornee and getInstancePosition(adornee) or getInstancePosition(billboardGui.Parent)
			local magnitude = (camera.CFrame.Position - origin).Magnitude
			local distanceRatio = ((billboardGui:GetAttribute("Distance") or Default_Billboard_Distance)/magnitude)
			for stroke, originalThickness in BillboardStrokes do
				if not stroke.Parent then
					BillboardStrokes[stroke] = nil
				else
					stroke.Thickness = originalThickness * distanceRatio * getScreenRatio()
				end
			end
		end
	end)
end)



-- 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 ||--
local UIStrokeAdjuster = {}

function UIStrokeAdjuster:TagScreenGui(screenGui: ScreenGui)
	if screenGui:IsA(screenGui) then
		CollectionService:AddTag(screenGui, Screen_Gui_Tag)
	end
end

function UIStrokeAdjuster:TagBillboardGui(billboardGui: BillboardGui)
	if billboardGui:IsA(billboardGui) then
		CollectionService:AddTag(billboardGui, Billboard_Tag)
	end
end

return UIStrokeAdjuster

UPDATE 1/25/2024

197 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…

4 Likes

Much cleaner than my implementation!

1 Like

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

1 Like

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.

5 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

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
3 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:

2 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!!

Why isn’t this already a feature :confused:

10 Likes

Thanks so much! This works great.

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.

10 Likes

UIStrokeAdjuster:TagBillboardGui(BillboardGui) - Use this

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.

3 Likes

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

1 Like

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

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

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

2 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

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