Screen3D - A 3D UI framework that just works

Fair point. Thanks for the explanation. I had this thought that maybe the parts used for these UI elements could house the ability to emit particles (like actual ParticleEmitter instances).

Upon checking, that isn’t possible as it’s not within workspace. However, this is a resource to achieve a similar result to Phighting, and it’s not like Phighting really does that but it would be pretty cool.

Attachments with Particles would compliment some cool designs and I actually thought cuz it was 3D you could do this and not just perspective work that is impossible on the regular UI system.

That’s all I suppose

anything’s possible with an unhinged enough workaround…!

also although the adornee parts arent in workspace, there’s technically nothing stopping you from moving them there. screen3d won’t break if you end up changing the adornee parents (i think)

the only reason screen3d doesn’t put the adornees in workspace is so that they don’t interfere with raycasts

You can just assign the Parts CollisionGroup to a group dedicated for Raycasts where Raycast cannot intersect with Raycasts. If that’s really the only reason, I suppose that would be a solution for adding it in. Would remove the parts from the PlayerGui making things more easily organized and readable.

I do admit using a hack workaround to get it working still means it’s possible, but I was looking at it from the perspective of it not being almost completely changed systematically.

I appreciate the insight and will try at some point myself.

Or you can just set CanQuery to false :V Also if you wanna be more organized you can put all your Screen3D parts under the Camera

brah :broken_heart: turns out bindtorenderstep doesnt fix issues with a custom shiftlock implementation. I tried offsetting the cameraoffset and cframe of the camera but both just put the UI out of frame or moved without the UI.

I have like no idea how to fix ts

Heres a video to better show whats going on

heres a snippet of the code that changes the CFrame of the camera

	self.RuntimeMaid:GiveTask(RunService.RenderStepped:Connect(function()

		-- Check If In First Person
		if self.Head.LocalTransparencyModifier > 0.6 then return end

		local CameraCFrame = Camera.CoordinateFrame
		local Distance = (self.Head.Position - CameraCFrame.Position).magnitude

		-- Check Camera Distance
		if Distance > 1 then	

			if MainModuleSettings.SpringOffsetUpdate == true then
				
				-- Offset Camera's CFrame based on CameraOffsetSpring's position
				Camera.CFrame = (Camera.CFrame * CFrame.new(self.Dependencies.ShiftlockDependency.CameraOffsetSpring.Position))
			end

			if MainModuleSettings.MouseBehaviourUpdate == true then

				-- Constantly Update Mouse State
				if self.Dependencies.ShiftlockDependency.ShiftlockEnabled and UserInputService.MouseBehavior ~= Enum.MouseBehavior.LockCenter then
					self.Dependencies.MouseDependency:SetMouseBehavior(self.Dependencies.ShiftlockDependency.ShiftlockEnabled)
				end
			end
		end

	end))

speaking of being organized, heres a way to improve debugging if you need to access the 3d UI in PlayerGui.

(This helped me, it may not help you.)

	local Component2DName = self.component2D.Name
	
	local surfaceGui = Instance.new('SurfaceGui')
	surfaceGui.Name = Component2DName
	
	local surfacePart = Instance.new('Part')
	surfacePart.Name = Component2DName .. "UIPart"

2 Likes

try messing with the RenderPriority of screen3d or the shiftlock module

Yea I tried every single priority and even switched to renderstepped and heartbeat but nothing worked.

How did you get the health bar to do that? I’ve been trying and all my UI does is fly around

				RunService.RenderStepped:Connect(function(DeltaTime)
					local RootPartAssemblyLinearVelocity = Player.Character.HumanoidRootPart.AssemblyLinearVelocity

					if RootPartAssemblyLinearVelocity == 0 then
						return
					end

					local RelativeVelocity = Camera.CFrame.Rotation:ToObjectSpace(CFrame.new(RootPartAssemblyLinearVelocity * Sensitivity)).Position
					local LerpAlpha = math.min(DeltaTime * LerpSpeed, 1)

					if RelativeVelocity.Magnitude ~= 0 then
						RelativeVelocity = math.min(MaxVelocity, RelativeVelocity.Magnitude  ) * RelativeVelocity.Unit
					end

					ThreeDHudGUI.rootOffset = ThreeDHudGUI.rootOffset:Lerp(CFrame.new(RelativeVelocity), LerpAlpha)
				end)
local LerpSpeed = 2
local Sensitivity = .01

local MaxVelocity = .25
1 Like

How did you make this effect? the UI movements at the same time as the character?

This was actually written by

@yy_qat

Variables: The characters assemblylinearvelocity property, the root offset property of the screengui instance and settings.

Calculation: Calculate the LerpAlpha variable using the Deltatime variable from the run service connection and the LerpSpeed setting. Calculate the RelativeVelocity variable by taking our Camera CFrame Rotation, using :ToObjectSpace and creating a new CFrame then multiplying our AssemblyLinearVelocity variable by the sensitivity setting and getting the output position as our new CFrame.

Checks: if the character is not moving (RootAssemblyLinearVelocity variable is zero) then we return end. If the RelativeVelocity Variable’s magnitude is not zero then we math.min it to stop it from flinging the UI to mars. Using the MaxVelocity variable and Relative Velocity’s Magnitude and Unit property’s.

Setting: Finally, after all that we Lerp the root offset of the 3D UI object.

(I’m SaintImmor btw)

Here is the even MORE complicated version.

RunService.RenderStepped:Connect(function(DeltaTime)
					
					local success, errorMessage = pcall(function()
						
						-- check if the Character/ RootPart Exist
						if not Player.Character or not Player.Character:FindFirstChild("HumanoidRootPart") then
							return
						end

						-- make sure ThreeDHudGUI exists and has rootOffset property
						if not ThreeDHudGUI or not ThreeDHudGUI.rootOffset then
							warn("ThreeDHudGUI or rootOffset not found. Make sure Screen3D module is properly initialized.")
							return
						end

						FrameCount = FrameCount + 1
						if FrameCount % MaxFrameSkip ~= 0 and DeltaTime < 1/120 then
							return
						end

						local RootPart = Player.Character.HumanoidRootPart
						local RootPartAssemblyLinearVelocity = RootPart.AssemblyLinearVelocity

						local effectiveDeadZone = DeadZone

						--If we cant get the RootPartAssemblyLinearVelocity then warn and return
						if not RootPartAssemblyLinearVelocity then
							warn("Failed to get AssemblyLinearVelocity from RootPart")
							return
						end

						if tick() - LastSignificantUpdate < 0.2 then
							effectiveDeadZone = DeadZone * 0.7
						end

						LastSignificantUpdate = tick()

						-- dont freeze if there isnt a RootPart
						if not RootPart then
							-- smoothly fade out effect when player is invalid
							local currentOffset = ThreeDHudGUI.rootOffset
							if currentOffset.Position.Magnitude > 0.01 then -- only update if there's a meaningful offset
								local FadeAlpha = math.min(DeltaTime * ReturnSpeed, 1)
								ThreeDHudGUI.rootOffset = currentOffset:Lerp(CFrame.new(), FadeAlpha)
							end
							return
						end

						-- check if velocity is essentially zero (more reliable than == 0)
						if RootPartAssemblyLinearVelocity.Magnitude < 0.1 then
							-- smoothly return to center when not moving
							local LerpAlpha = math.min(DeltaTime * LerpSpeed, 1)
							ThreeDHudGUI.rootOffset = ThreeDHudGUI.rootOffset:Lerp(CFrame.new(), LerpAlpha)
							return
						end

						-- convert world velocity to camera-relative space
						local RelativeVelocity = Camera.CFrame.Rotation:ToObjectSpace(
							CFrame.new(RootPartAssemblyLinearVelocity * Sensitivity)
						).Position

						-- clamp velocity to maximum (moved before lerp calculation for efficiency)
						if RelativeVelocity.Magnitude > 0 then
							local ClampedMagnitude = math.min(MaxVelocity, RelativeVelocity.Magnitude)
							RelativeVelocity = RelativeVelocity.Unit * ClampedMagnitude
						end

						-- smooth interpolation
						local LerpAlpha = math.min(DeltaTime * LerpSpeed, 1)
						ThreeDHudGUI.rootOffset = ThreeDHudGUI.rootOffset:Lerp(CFrame.new(RelativeVelocity), LerpAlpha)

					end)
					
					if not success then
						warn("Velocity Effect Error: " .. tostring(errorMessage))
						-- optionally disconnect on repeated errors to prevent spam
						if errorMessage and string.find(errorMessage, "ThreeDHudGUI") then
							VelocityConnection:Disconnect()
							warn("connection disconnected due to missing ThreeDHudGUI")
						end
					end
					
				end)
		local MaxFrameSkip = 3
		local FrameCount = 0
		local LastSignificantUpdate = 0
		local DeadZone = .2
		local ReturnSpeed = 8

Note that alot of this code was brushed up by Claude 4.0. No im not a vibe coder I just problem checked and added some stuff with AI.

Hi, ive tried using this module, but ive seen to have some issues with it.
For some odd reason when i want the gui to look to left by setting the y to math.rad(-15) it goes off the screen, no idea how to fix it.
heres my code:
local RS = game:GetService(“ReplicatedStorage”)
local Event = RS:WaitForChild(“Remotes”):WaitForChild(“EquipEvent”)
local skillsetModule = require(RS:WaitForChild(“SkillSets”))

– 3D GUI Setup
local mainUI: ScreenGui = script.Parent
local screenGen = require(RS:WaitForChild(“Assets”):WaitForChild(“Modules”):WaitForChild(“Screen3D”))
local screen3D = screenGen.new(mainUI, 1)

– Helper function to safely wait for a child with a timeout
local function waitForChildWithTimeout(parent, childName, timeout)
local startTime = os.clock()
while not parent:FindFirstChild(childName) do
if os.clock() - startTime > timeout then
warn(“Timeout waiting for:”, childName)
return nil
end
task.wait(0.1)
end
return parent:FindFirstChild(childName)
end

– Wait for the Container to exist (created by Screen3D if needed)
local container = waitForChildWithTimeout(mainUI, “Container”, 5)
if not container then
error(“Container was not found after waiting. Make sure it exists in GUI or is created by Screen3D.”)
end

– Set up UI3d AFTER container is found
local UI3d = screen3D:GetComponent3D(container)

UI3d:Enable()
UI3d.offset = CFrame.Angles(math.rad(0), math.rad(-5), math.rad(0))

– Store references to frames
local frames = {}
for i = 1, 6 do
local frame = waitForChildWithTimeout(container, “Frame” … i, 5)
if not frame then
error(“Frame” … i … " not found!")
end
frames[i] = frame
end

– Helper to update GUI skill text
local function updateSkillText(skills)
for i, frame in ipairs(frames) do
local imageLabel = waitForChildWithTimeout(frame, “ImageLabel”, 5)
if imageLabel then
local textLabel = waitForChildWithTimeout(imageLabel, “TextLabel”, 5)
if textLabel then
textLabel.Text = skills[i] or “N/A”
else
warn(“TextLabel missing in Frame” … i)
end
else
warn(“ImageLabel missing in Frame” … i)
end
end
end

– Listen to EquipEvent
Event.OnClientEvent:Connect(function(toolName, action)
print(“Received:”, toolName, action)

if action == "Equipped" then
	local skillSet = skillsetModule[toolName]
	if not skillSet then
		warn("No skill set found for tool: " .. toolName)
		updateSkillText({"N/A", "N/A", "N/A", "N/A", "N/A", "N/A"})
		return
	end

	local eSkill = skillSet["E"] and skillSet["E"].Key or "N/A"
	local rSkill = skillSet["R"] and skillSet["R"].Key or "N/A"
	local tSkill = skillSet["T"] and skillSet["T"].Key or "N/A"
	local m1Skill = skillSet["M1"] and skillSet["M1"].Key or "N/A"
	local m2Skill = skillSet["M2"] and skillSet["M2"].Key or "N/A"
	local qSkill = skillSet["Q"] and skillSet["Q"].Key or "N/A"

	updateSkillText({eSkill, rSkill, tSkill, m1Skill, m2Skill, qSkill})
else
	updateSkillText({"N/A", "N/A", "N/A", "N/A", "N/A", "N/A"})
end

end)

Really liking this resource. However, I came across a problem where the ZIndex of nested elements with a negative offset (anchorpoint > 0 and position = 0 or position < 0 yeilds the same results.) isn’t being respected once the viewport size gets too big. The version I am using is the latest release on github (without the version control stuff.)

I imagine this is mainly issue with how ui elements that hang off the side of a SurfaceGui are rendered by the engine but if you can think of a potential workaround that would be great.

this is a known issue which i’ll fix when i get on pc

it has a really simple fix

while you wait, just replace the “1” with “10000” in Component3D

1 Like