Files
clients/2016/content/scripts/Modules/Panel3D.lua
T
2026-07-06 13:39:13 -04:00

491 lines
15 KiB
Lua

--Panel3D: 3D GUI panels for VR
--written by 0xBAADF00D
local PIXELS_PER_STUD = 64
local SETTINGS_DISTANCE = 3.5
local CURSOR_HIDE_TIME = 2
local CURSOR_FADE_TIME = 0.125
local CoreGui = game:GetService('CoreGui')
local RobloxGui = CoreGui:WaitForChild("RobloxGui")
local Panel3D = {}
Panel3D.Panels = {
Lower = 1,
Hamburger = 2,
Settings = 3,
Keyboard = 4,
Chat = 5
}
Panel3D.Orientation = {
Horizontal = 1,
Fixed = 2
}
Panel3D.Visibility = {
BelowAngleThreshold = 1,
Modal = 2,
Normal = 3
}
local panelDefaultVectors = {
[Panel3D.Panels.Lower] = CFrame.Angles(math.rad(-45), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5)),
[Panel3D.Panels.Hamburger] = CFrame.Angles(math.rad(-55), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5)),
[Panel3D.Panels.Settings] = Vector3.new(0, 0, -SETTINGS_DISTANCE),
[Panel3D.Panels.Keyboard] = CFrame.Angles(math.rad(-22.5), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5)),
[Panel3D.Panels.Chat] = CFrame.Angles(math.rad(5), 0, 0):vectorToWorldSpace(Vector3.new(0, 0, -5))
}
local DEFAULT_PANEL_LOCK_THRESHOLD = math.rad(-25)
local panelTransparencyBias = { --tuned values; raise the opacity value to this power
[Panel3D.Panels.Lower] = 6.5,
[Panel3D.Panels.Hamburger] = 8,
[Panel3D.Panels.Settings] = 0,
[Panel3D.Panels.Keyboard] = 1.5,
[Panel3D.Panels.Chat] = 1.5
}
local panels = {}
local headScale = 1
local renderStepName = "Panel3D"
local cursor = Instance.new("ImageLabel")
cursor.Image = "rbxasset://textures/Cursors/Gamepad/Pointer.png"
cursor.Size = UDim2.new(0, 8, 0, 8)
cursor.BackgroundTransparency = 1
cursor.ZIndex = 10
local resetOrientationFlag = false
local currentModalPanel = nil
local GuiService = game:GetService("GuiService")
local UserInputService = game:GetService("UserInputService")
local cursorHidden = false
local hasTool = false
local lastMouseMove = tick()
local function OnCharacterAdded(character)
hasTool = false
for i, v in ipairs(character:GetChildren()) do
if v:IsA("Tool") then
hasTool = true
end
end
character.ChildAdded:connect(function(child)
if child:IsA("Tool") then
hasTool = true
lastMouseMove = tick() --kick the mouse when a tool is equipped
end
end)
character.ChildRemoved:connect(function(child)
if child:IsA("Tool") then
hasTool = false
end
end)
end
spawn(function()
while not game.Players.LocalPlayer do wait() end
game.Players.LocalPlayer.CharacterAdded:connect(OnCharacterAdded)
if game.Players.LocalPlayer.Character then OnCharacterAdded(game.Players.LocalPlayer.Character) end
end)
local function autoHideCursor(hide)
if not UserInputService.VREnabled then
cursorHidden = false
return
end
if hide then
--don't hide if there's a tool in the character
local character = game.Players.LocalPlayer.Character
if character and hasTool then
return
end
cursorHidden = true
UserInputService.OverrideMouseIconBehavior = Enum.OverrideMouseIconBehavior.ForceHide
else
cursorHidden = false
UserInputService.OverrideMouseIconBehavior = Enum.OverrideMouseIconBehavior.None
end
end
local function isCursorVisible()
--if ForceShow, the cursor is definitely visible at all times
if UserInputService.OverrideMouseIconBehavior == Enum.OverrideMouseIconBehavior.ForceShow then
return true
end
--if ForceHide, the cursor is definitely NOT visible
if UserInputService.OverrideMouseIconBehavior == Enum.OverrideMouseIconBehavior.ForceHide then
return false
end
--Otherwise, we need to check if the developer set MouseIconEnabled=false
if UserInputService.MouseIconEnabled and UserInputService.OverrideMouseIconBehavior == Enum.OverrideMouseIconBehavior.None then
return true
end
return false
end
UserInputService.InputChanged:connect(function(inputObj, processed)
if inputObj.UserInputType == Enum.UserInputType.MouseMovement then
lastMouseMove = tick()
autoHideCursor(false)
end
end)
game:GetService("RunService").Heartbeat:connect(function()
if isCursorVisible() then
cursorHidden = false
end
if lastMouseMove + CURSOR_HIDE_TIME < tick() and not GuiService.MenuIsOpen and not cursorHidden then
autoHideCursor(true)
end
end)
local function createPanel()
local panelPart = Instance.new("Part")
panelPart.Transparency = 1
panelPart.CanCollide = false
panelPart.Anchored = true
panelPart.Archivable = false
panelPart.Size = Vector3.new(1.5, 1.5, 1)
panelPart.Parent = workspace.CurrentCamera
local panelGUI = Instance.new("SurfaceGui", CoreGui)
panelGUI.Name = "GUI"
panelGUI.Adornee = panelPart
panelGUI.ToolPunchThroughDistance = 1000
panelGUI.Active = true
pcall(function() --todo: remove this when api is live
panelGUI.AlwaysOnTop = true
end)
return panelPart, panelGUI
end
function Panel3D.Get(panelId)
local panel = panels[panelId]
if not panel then
local panelName
for name, value in pairs(Panel3D.Panels) do
if value == panelId then
panelName = name
break
end
end
if not panelName then
error("Tried to request an invalid 3D panel")
end
local part, gui = createPanel()
panel = {}
panel.part = part
panel.part.Name = "GUI"--("%sPanel3D"):format(panelName)
panel.gui = gui
panel.gui.Name = ("%sPanelGUI"):format(panelName)
panel.vector = panelDefaultVectors[panelId]
panel.horizontalVector = Vector3.new(panel.vector.x, 0, panel.vector.z).unit
panel.pitchAngle = math.asin(panel.vector.unit.y)
panel.verticalRange = math.rad(5)
panel.horizontalRange = math.rad(5)
panel.transparencyCallbacks = {}
panel.OnMouseEnter = nil
panel.OnMouseLeave = nil
panel.pixelScale = 1
panel.visible = true
panel.visibilityBehavior = Panel3D.Visibility.BelowAngleThreshold
panel.orientationMode = Panel3D.Orientation.Horizontal
panel.orientation = nil
panel.cursorEnabled = true
panel.width = 1
panel.height = 1
function panel:AddTransparencyCallback(callback)
table.insert(panel.transparencyCallbacks, callback)
end
function panel:Resize(width, height, pixelsPerStud)
panel.width = width
panel.height = height
pixelsPerStud = pixelsPerStud or PIXELS_PER_STUD
panel.pixelScale = pixelsPerStud / PIXELS_PER_STUD
panel.part.Size = Vector3.new(panel.width * headScale, panel.height * headScale, 1)
panel.gui.CanvasSize = Vector2.new(pixelsPerStud * panel.width, pixelsPerStud * panel.height)
local distance = panel.vector.magnitude
panel.verticalRange = math.atan(panel.part.Size.Y / (2 * distance)) * 2
panel.horizontalRange = math.atan(panel.part.Size.X / (2 * distance)) * 2
end
function panel:ResizePixels(width, height)
local widthStuds = width / PIXELS_PER_STUD
local heightStuds = height / PIXELS_PER_STUD
panel:Resize(widthStuds, heightStuds)
end
function panel:SetModal()
panel.visible = false
panel.visibilityBehavior = Panel3D.Visibility.Modal
if panel.visible then
currentModalPanel = panel
if panel.orientationMode == Panel3D.Orientation.Fixed then
local userHeadCFrame = UserInputService:GetUserCFrame(Enum.UserCFrame.Head)
panel.orientation = userHeadCFrame * CFrame.new(0, 0, -panel.vector.magnitude) * CFrame.Angles(0, math.pi, 0)
end
end
end
function panel:SetVisible(visible)
panel.visible = visible
if visible and panel.visibilityBehavior == Panel3D.Visibility.Modal then
currentModalPanel = panel
if panel.orientationMode == Panel3D.Orientation.Fixed then
local userHeadCFrame = UserInputService:GetUserCFrame(Enum.UserCFrame.Head)
panel.orientation = userHeadCFrame * CFrame.new(0, 0, -panel.vector.magnitude) * CFrame.Angles(0, math.pi, 0)
end
else
if currentModalPanel == panel then
currentModalPanel = nil
end
end
end
panels[panelId] = panel
end
return panel
end
function Panel3D.ResetOrientation()
resetOrientationFlag = true
end
function Panel3D.GetGUI(panel)
local panelGUI = panelGUIs[panel]
if not panelGUI then
local part = Panel3D.GetPart(panel)
panelGUI = panelGUIs[panel]
end
return panelGUI
end
local zeroVector = Vector3.new(0, 0, 0)
local headXZ = CFrame.new()
local headOffset = Vector3.new()
local currentHoverPanel = nil
local savedMouseBehavior = Enum.MouseBehavior.Default
function Panel3D.OnRenderStep()
if not UserInputService.VREnabled then
return
end
local cameraCFrame = workspace.CurrentCamera.CFrame
local cameraRenderCFrame = workspace.CurrentCamera:GetRenderCFrame()
local userHeadCFrame = UserInputService:GetUserCFrame(Enum.UserCFrame.Head)
local userHeadLook = userHeadCFrame.lookVector
local userHeadHorizontalVector = Vector3.new(userHeadLook.X, 0, userHeadLook.Z).unit
local userHeadPitch = math.asin(userHeadLook.Y)
local panelLockThreshold = DEFAULT_PANEL_LOCK_THRESHOLD
for panelId, panel in pairs(panels) do
if panel.pitchLockThreshold and panel.visible then
panelLockThreshold = math.max(panelLockThreshold, panel.pitchLockThreshold)
end
end
local isAboveThreshold = false
if userHeadPitch > panelLockThreshold or resetOrientationFlag then
isAboveThreshold = true
headXZ = CFrame.new(zeroVector, userHeadHorizontalVector)
headOffset = userHeadCFrame.p
resetOrientationFlag = false
end
local panelsOrigin = cameraCFrame * CFrame.new(headOffset) * headXZ
for panelId, panel in pairs(panels) do
if panel.visibilityBehavior == Panel3D.Visibility.BelowAngleThreshold then
panel.visible = not (isAboveThreshold or currentModalPanel)
end
if not panel.visible then
panel.part.Parent = nil
panel.gui.Adornee = nil
else
panel.part.Parent = workspace.CurrentCamera --TODO: move to new 3D gui space
panel.gui.Adornee = panel.part
local panelCFrame;
if panel.orientationMode == Panel3D.Orientation.Fixed and panel.orientation then
local pos = panel.orientation.p * headScale
panel.part.CFrame = workspace.CurrentCamera.CFrame * ((panel.orientation - panel.orientation.p) + pos)
panelCFrame = panel.part.CFrame
else
local panelPosition = panelsOrigin:pointToWorldSpace(panel.vector * headScale)
panelCFrame = CFrame.new(panelPosition, panelsOrigin.p)
panel.part.CFrame = panelCFrame
end
local toPanel = (panelCFrame.p - cameraRenderCFrame.p).unit
local transparency = panel.visible and 1 - (math.max(0, cameraRenderCFrame.lookVector:Dot(toPanel)) ^ panelTransparencyBias[panelId]) or 1
for _, callback in pairs(panel.transparencyCallbacks) do
callback(transparency)
end
end
end
--Render a cursor overlaid onto the panels
local cframe = cameraRenderCFrame
local ray = Ray.new(cframe.p, cframe.lookVector * 999)
local ignoreList = { game.Players.LocalPlayer.Character }
local part, endpoint = workspace:FindPartOnRayWithIgnoreList(ray, ignoreList)
local hitPanel = nil
local hitPanelId = nil
for panelId, panel in pairs(panels) do
if part == panel.part then
hitPanel = panel
hitPanelId = panelId
end
end
if hitPanel then
if not currentHoverPanel then
savedMouseBehavior = UserInputService.MouseBehavior
UserInputService.MouseBehavior = Enum.MouseBehavior.LockCenter
else
if currentHoverPanel ~= hitPanel then
if currentHoverPanel.OnMouseLeave then
currentHoverPanel:OnMouseLeave()
end
end
end
if hitPanel.OnMouseEnter and currentHoverPanel ~= hitPanel then
hitPanel:OnMouseEnter()
end
currentHoverPanel = hitPanel
UserInputService.OverrideMouseIconBehavior = Enum.OverrideMouseIconBehavior.ForceHide
if hitPanel.cursorEnabled then
cursor.Parent = hitPanel.gui
else
cursor.Parent = nil
end
local localEndpoint = part:GetRenderCFrame():pointToObjectSpace(endpoint)
local x = (localEndpoint.X / part.Size.X) + 0.5
local y = (localEndpoint.Y / part.Size.Y) + 0.5
x = 1 - x
y = 1 - y
cursor.Size = UDim2.new(0, 8 * hitPanel.pixelScale, 0, 8 * hitPanel.pixelScale)
cursor.Position = UDim2.new(x, -cursor.AbsoluteSize.x * 0.5, y, -cursor.AbsoluteSize.y * 0.5)
if hitPanel.visible and not modalPanelIsOpen then
hitPanel.part.Parent = workspace.CurrentCamera
for _, callback in pairs(hitPanel.transparencyCallbacks) do
callback(0)
end
end
else
if currentHoverPanel then
UserInputService.MouseBehavior = savedMouseBehavior
if currentHoverPanel.OnMouseLeave then
currentHoverPanel:OnMouseLeave()
end
end
currentHoverPanel = false
cursor.Parent = nil
end
end
-- RayPlaneIntersection
-- http://www.siggraph.org/education/materials/HyperGraph/raytrace/rayplane_intersection.htm
local function RayPlaneIntersection(ray, planeNormal, pointOnPlane)
planeNormal = planeNormal.unit
ray = ray.Unit
-- compute Pn (dot) Rd = Vd and check if Vd == 0 then we know ray is parallel to plane
local Vd = planeNormal:Dot(ray.Direction)
-- could fuzzy equals this a little bit to account for imprecision or very close angles to zero
if Vd == 0 then -- parallel, no intersection
return nil
end
local V0 = planeNormal:Dot(pointOnPlane - ray.Origin)
local t = V0 / Vd
if t < 0 then --plane is behind ray origin, and thus there is no intersection
return nil
end
return ray.Origin + ray.Direction * t
end
function Panel3D.FindHoveredGuiElement(panel, elements)
local cameraRenderCFrame = workspace.CurrentCamera and workspace.CurrentCamera:GetRenderCFrame()
local panelPart = panel.part
if cameraRenderCFrame and panelPart then
local panelPartSize = panelPart.Size
local panelSurfaceCFrame = panelPart.CFrame + panelPart.CFrame.lookVector * (panelPartSize.Z * 0.5)
local intersectionPt = RayPlaneIntersection(Ray.new(cameraRenderCFrame.p, cameraRenderCFrame.lookVector), panelSurfaceCFrame.lookVector, panelSurfaceCFrame.p)
if intersectionPt then
local localPoint = panelSurfaceCFrame:pointToObjectSpace(intersectionPt) * Vector3.new(-1, 1, 1) + Vector3.new(panelPartSize.X/2, -panelPartSize.Y/2, 0)
local guiPoint = Vector2.new((localPoint.X / panelPartSize.X) * panel.gui.AbsoluteSize.X, (localPoint.Y / panelPartSize.Y) * -panel.gui.AbsoluteSize.Y)
local x = guiPoint.x
local y = guiPoint.y
for _, item in pairs(elements) do
local minPt = item.AbsolutePosition
local maxPt = item.AbsolutePosition + item.AbsoluteSize
if minPt.X <= x and maxPt.X >= x and minPt.Y <= y and maxPt.Y >= y then
return item
end
end
end
end
end
game:GetService("RunService"):BindToRenderStep(renderStepName, Enum.RenderPriority.Last.Value, Panel3D.OnRenderStep)
local function OnCameraChanged(prop)
if prop == "HeadScale" then
pcall(function()
headScale = workspace.CurrentCamera.HeadScale
end)
for i, v in pairs(panels) do
v:Resize(v.width, v.height, v.pixelScale * PIXELS_PER_STUD)
end
end
end
local cameraChangedConn = nil
workspace.Changed:connect(function(prop)
if prop == "CurrentCamera" then
OnCameraChanged("HeadScale")
if cameraChangedConn then
cameraChangedConn:disconnect()
end
cameraChangedConn = workspace.CurrentCamera.Changed:connect(OnCameraChanged)
end
end)
if workspace.CurrentCamera then
OnCameraChanged("HeadScale")
cameraChangedConn = workspace.CurrentCamera.Changed:connect(OnCameraChanged)
end
return Panel3D