Hologram | Roblox's First 3D ProximityPrompt System from games like Detroit: Become Human | v0.1.5

Hologram

Roblox's First 3D ProximityPrompt System

Roblox Creator Store

Hey Developers!

I’ve always liked the 3D Prompts from Detroit: Become Human, the ones that truly feel like they’re part of the world, seamlessly blending into the 3D space rather than appearing as flat, 2D pop-ups. They make interactions feel immersive, dynamic, and alive.

I experimented with making this system on Roblox and was pleased to see how it turned out. Therefore, I have made it a standalone module, which is heavily customisable, that you guys can use to heighten your game’s quality!

Introducing Hologram

A fully customisable module that transforms Roblox’s ProximityPrompts from static, 2D UI elements into stunning, depth-based 3D prompts. With *Hologram*, you can add a layer of immersion and polish to your game that truly sets it apart.

Installation

Get it on the Roblox Creator Store!

Features

Holograms work on all sides of the part!

Customise Colour:

Offset your Prompt:

Add a cool beam & highlight for coolness?:

Works on Mobile & Console:


Works with multiple Holograms:

Works like the traditional ProximityPrompt:

Want more features? Feel free to request down below!

Documentation

To create a new Hologram, you must first create the default ProximityPrompt instance, like so:
Screenshot 2025-01-28 at 3.00.19 pm

Make sure the ProximityPrompt has its style set to ‘Custom’
Screenshot 2025-01-28 at 3.00.59 pm

In a local script, require the module and create a Hologram:

local Hologram = require(Path.To.Hologram)
local ProximityPrompt = Path.To.ProximityPrompt

local CustomPrompt = Hologram.New(ProximityPrompt)

And voila! Your Hologram should work in game!

The creation function has four arguments:

Hologram.New(Prompt: ProximityPrompt, StudsOffset: Vector3?, CheckTag: boolean?, ShowBeam: boolean?)

--[[
   Prompt (ProximityPrompt): The prompt you want to convert to a Hologram
   StudsOffset (Vector3): The 3D Offset applied to the UI
   CheckTag (boolean): Check whether ProximityPrompt has tag "Hologram" (in case you have other designs for 2D prompts)
   ShowBeam (boolean): Show a beam connecting to the ProximityPrompt's parent. (Polish) 
]]

The Global Creation function has two arguments:

Hologram.InitialiseGlobally(CheckTag: boolean, ShowBeam: boolean)

--[[
   CheckTag (boolean): Check whether ProximityPrompt has tag "Hologram" (in case you have other designs for 2D prompts)
   ShowBeam (boolean): Show a beam connecting to the ProximityPrompt's parent. (Polish) 
]]

It also has a few settings:

--// Colour Values

Hologram:SetPrimaryColour(Colour: Color3)
Hologram:SetSecondaryColour(Colour: Color3)
Hologram:SetTertiaryColour(Colour: Color3)

--// Initial Settings
Hologram:SetStudsOffset(StudsOffset: Vector3)
Hologram:SetShowBeam(ShowBeam: boolean)
Hologram:SetAlwaysOnTop(isOnTop: boolean) -- sets SurfaceGui property (Hologram UI)
Hologram:SetBillboardActive(isActive: boolean)

You can access Holograms from anywhere (given you have the ProximityPrompt) using this:

local ProximityPrompt = Path.To.ProximityPrompt

local SomeHologram = Hologram.Holograms[ProximityPrompt]
SomeHologram:SetShowBeam(true)

Want to test out the Hologram module in action? Here's an uncopylocked place: Holograms

Contact

Contact me on Twitter or Discord (username: elitriare), or just on this thread to report bugs or request features (I would love to add more!). I haven’t fully tested this across different types of experiences, so your feedback is extremely useful!

This project is open-source.

59 Likes

Now this is something cool, one thing I want to say is that I think it’s too big in the showcase

3 Likes

Is there any way to make it face the camera like a BillboardGui? From the showcases it seems like the holograms always face outwards from the closest face on the part

1 Like

Its really hard to get this to work consistently. I think there should be an option to have it apply to every proximityprompt automatically.

Besides that, all ive noticed is its very sensitive to turning off from small movements.

Doesnt support r6 with the beam feature.

Has a hard time deciding which side I came from

sometimes itll position wrong and the hologram wont even be visible because the object is shaped a little weird.

2 Likes

Yeah that was the intention. It’s not supposed to act like a BillboardGUI.

1 Like

I can definitely make an apply-all feature. What do you mean by sensitive to small movements? It is completely reliant on the ProximityPrompt events, so it acts exactly like one. Maybe increase distance?

R6 Support is something I overlooked, will fix.

As for deciding which side you come from, it looks for the face of a part closest to the camera. The one that is most visible.

Could you send me a video of the weird object?

1 Like

Version 0.1.1

Upgrades

  • Added an InitialiseGlobally() function to initialise Hologram for every ProximityPrompt.
  • Added SetShowBeam() & SetStudsOffset() settings to Hologram.
  • You can now access every Hologram registered using Hologram.Holograms[ProximityPrompt]
  • R6 Support

Now available on the Creator Store.
@SaintImmor, this might resolve some of the problems, please let me know.

2 Likes

Version 0.1.2

Upgrades

  • Added Billboard GUI-type setting for it to always face player.

Now available on the Creator Store.
@athar_adv, this might be what you are looking for.

1 Like

.Initialiseglobally doesnt work for some reason… Even with checktag off it cant seem to pick up on my proximityprompts…

Mind testing it?

Also a test place for this would be nice since setting it up is sometimes a hassle.

1 Like

Version 0.1.3

Fixes

  • Fixed positioning to account for size more accurately. Now it should pop up right in front of the part’s face.
  • Fixed mobile to actually fire InputHold & InputEnd events.
  • Fixed FaceNormals calculation. Hologram should now accurately display on top of the correct face.

Now available on the Creator Store.

1 Like

I’m making a demo place right now, I’ll get it done today.

I have included a Demo place link above. Hope that helps.

This is a very cool system, one thing that could be improved on is when the hold duration is set to 0, there isn’t any feedback. Maybe just a little something to let the player know it was triggered I think would be very nice.

1 Like

Good point, I’ll develop that soon.

Just want to say, this is a great system, I love the way you made it and I believe it’s useful for many up-and-coming games! However, there are some things to fix or add that could make it better.

For a start, it would be nice to make it so if the Hologram is open and the player switches sides with it open, for the Hologram to also move to the side the player is on. As of now; If the player opens the Hologram and then goes to a different side of the part, the Hologram does not follow the new side.

Also, a bug that can stop the Hologram from showing is: If the player enters the range of which a Hologram is shown, if the player exits the range of the Hologram and then reenters the range before tweening has finished, the new Hologram will disappear, this is due to your FinalTween variable in OnPromptHidden.

Now, I’ve taken it upon myself to make a quick fix to your system for the bug that causes a Hologram to not show, however the side switching was not done by me and is still your choice if you wish to add or not add it.

Here are the steps to add the fix:

  1. In the InitialiseGlobally function, update this line so that Hologram.New isn’t being stored unnecessarily in the Holograms table, this is mostly for optimization, since the rest of the changes don’t actually use the Holograms table.
Hologram.New(Prompt, Vector3.zero, CheckTag, ShowBeam)
  1. Inside the .New function, add a new table to keep track of cloned prompts. This will make managing multiple prompts easier, cause, well we like easy things.
self.ClonedPrompts = {}
  1. Replace the CreatePrompt function to properly handle cloned prompts, this is a hefty change.
function Hologram:CreatePrompt()
	local CloneUUID = os.clock() + math.random()

	local ClonedPrompt = PromptTemplate:Clone()

	local KeyCodePart = ClonedPrompt.KeyCode
	local KeyCodeUI = KeyCodePart.HologramKeyCodeUI
	local KeyCodeBackground = KeyCodeUI.Background
	local KeyCodeText = KeyCodeBackground.KeyCode
	local KeyCodeImage = KeyCodeBackground.KeyCodeImage
	local KeyCodeProgress = KeyCodeBackground.Progress

	local InstructionPart = ClonedPrompt.Instruction
	local InstructionUI = InstructionPart.HologramInstructionUI
	local InstructionBackground = InstructionUI.Background
	local ActionUIText = InstructionBackground.Action
	local ObjectUIText = InstructionBackground.Object
	local InvisibleButton = InstructionBackground.InvisiButton

	local DesignPart = ClonedPrompt.Design
	local DesignUI = DesignPart.HologramDesignUI
	local DesignImage = DesignUI.Image

	KeyCodeUI.Name = "Hologram" .. self.PromptName .. CloneUUID .. "KeyCodeUI"
	InstructionUI.Name = "Hologram" .. self.PromptName .. CloneUUID .. "InstructionUI"
	DesignUI.Name = "Hologram" .. self.PromptName .. CloneUUID .. "DesignUI"

	local Beam = KeyCodePart.Beam
	local BeamAttachment = KeyCodePart.Attachment
	local TransparencyValue = Beam:WaitForChild("TransparencyValue")

	ClonedPrompt.Name = "Hologram" .. self.PromptName .. CloneUUID
	ClonedPrompt.Parent = self.PromptParent

	KeyCodeUI.Parent = PlayerUI
	InstructionUI.Parent = PlayerUI
	DesignUI.Parent = PlayerUI

	local BeamConnect = TransparencyValue:GetPropertyChangedSignal("Value"):Connect(function()
		Beam.Transparency = NumberSequence.new(TransparencyValue.Value)
	end)

	local CloneData = {
		ClonedPrompt = ClonedPrompt,
		KeyCodePart = KeyCodePart,
		KeyCodeUI = KeyCodeUI,
		KeyCodeBackground = KeyCodeBackground,
		KeyCodeText = KeyCodeText,
		KeyCodeImage = KeyCodeImage,
		KeyCodeProgress = KeyCodeProgress,
		InstructionPart = InstructionPart,
		InstructionUI = InstructionUI,
		InstructionBackground = InstructionBackground,
		ActionUIText = ActionUIText,
		ObjectUIText = ObjectUIText,
		InvisibleButton = InvisibleButton,
		DesignPart = DesignPart,
		DesignUI = DesignUI,
		DesignImage = DesignImage,
		Beam = Beam,
		BeamAttachment = BeamAttachment,
		TransparencyValue = TransparencyValue,
		BeamConnect = BeamConnect,
		UUID = CloneUUID,
	}

	table.insert(self.ClonedPrompts, CloneData)

	return CloneData
end
  1. Replace the DestroyPrompt function to actually use the CloneData
function Hologram:DestroyPrompt(CloneData)
	if not CloneData then return end

	for _, PromptUI in pairs(PlayerUI:GetChildren()) do
		if string.find(PromptUI.Name, "Hologram" .. self.PromptName .. CloneData.UUID) then
			PromptUI:Destroy()
		end
	end

	if CloneData.HoldEndedConnection then
		CloneData.HoldEndedConnection:Disconnect()
	end
	CloneData.BeamConnect:Disconnect()
	if CloneData.InvisiButtonConnect then
		CloneData.InvisiButtonConnect:Disconnect()
	end
	if CloneData.BillboardUI then
		CloneData.BillboardUI:Disconnect()
	end
	CloneData.ClonedPrompt:Destroy()

	for index, data in ipairs(self.ClonedPrompts) do
		if data == CloneData then
			table.remove(self.ClonedPrompts, index)
			break
		end
	end
end
  1. Replace the OnPromptShown function to manage multiple clones
function Hologram:OnPromptShown(InputType: Enum.ProximityPromptInputType)
	local CloneData = self:CreatePrompt()
	if not CloneData.ClonedPrompt then return end

	local ClosestFace = GetClosestFace(self.PromptParent)
	local FacePosition, FaceNormal

	if self.BillboardActive == true then
		FacePosition = self.PromptParent.CFrame:PointToWorldSpace(self.StudsOffset)
		FaceNormal = (Camera.CFrame.Position - FacePosition).Unit
	else
		FaceNormal = GetFaceNormals(self.PromptParent)[ClosestFace]
		FacePosition = self.PromptParent.Position + (FaceNormal * (self.PromptParent.Size / 2)) + (FaceNormal * 1)
	end

	CloneData.KeyCodeText.TextTransparency = 1
	CloneData.ActionUIText.TextTransparency = 1
	CloneData.ObjectUIText.TextTransparency = 1

	CloneData.InstructionBackground.Transparency = 1
	CloneData.KeyCodeBackground.Transparency = 1

	CloneData.DesignImage.ImageTransparency = 1
	CloneData.KeyCodeImage.ImageTransparency = 1

	CloneData.DesignImage.ImageColor3 = self.Colours.Primary
	CloneData.KeyCodeProgress.BackgroundColor3 = self.Colours.Primary

	CloneData.InstructionBackground.BackgroundColor3 = self.Colours.Secondary
	CloneData.KeyCodeBackground.BackgroundColor3 = self.Colours.Secondary

	CloneData.KeyCodeText.TextColor3 = self.Colours.Tertiary
	CloneData.KeyCodeImage.ImageColor3 = self.Colours.Tertiary
	CloneData.ActionUIText.TextColor3 = self.Colours.Tertiary
	CloneData.ObjectUIText.TextColor3 = self.Colours.Tertiary

	if self.BillboardActive == true then
		CloneData.BillboardUI = RunService.RenderStepped:Connect(function()
			if not CloneData.ClonedPrompt or not CloneData.ClonedPrompt.PrimaryPart then return end

			local PartCFrame = self.PromptParent.CFrame
			local WorldOffset = PartCFrame:PointToWorldSpace(self.StudsOffset)
			local CameraPosition = Camera.CFrame.Position

			CloneData.ClonedPrompt:PivotTo(CFrame.lookAt(WorldOffset, CameraPosition, Vector3.new(0, 1, 0)))

			CloneData.DesignPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * 0.05)
			CloneData.InstructionPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * -0.05) + (CloneData.KeyCodePart.CFrame.RightVector * -2.845)
		end)
	else
		CloneData.ClonedPrompt:PivotTo(CFrame.new(FacePosition, FacePosition + FaceNormal))
		CloneData.ClonedPrompt:PivotTo(CloneData.ClonedPrompt.PrimaryPart.CFrame + (CloneData.ClonedPrompt.PrimaryPart.CFrame.LookVector * (self.StudsOffset.Z)) + (CloneData.ClonedPrompt.PrimaryPart.CFrame.RightVector * (self.StudsOffset.X)) + (CloneData.ClonedPrompt.PrimaryPart.CFrame.UpVector * (self.StudsOffset.Y)))

		CloneData.DesignPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * 1)
		CloneData.InstructionPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * -1) + (CloneData.KeyCodePart.CFrame.RightVector * -2.845)
	end

	if self.ShowBeam == true then
		BorderHighlight.Adornee = self.PromptParent

		CloneData.Beam.Attachment1 = Character:WaitForChild("UpperTorso", 10).BodyFrontAttachment or Character:WaitForChild("Torso", 10).BodyFrontAttachment
		CloneData.BeamAttachment.Parent = self.PromptParent

		Tween(CloneData.TransparencyValue, "Value", 0)
	end

	if InputType == Enum.ProximityPromptInputType.Gamepad then
		if GamepadButtonImage[self.Prompt.GamepadKeyCode] then
			CloneData.KeyCodeImage.Image = GamepadButtonImage[self.Prompt.GamepadKeyCode]
		end
	elseif InputType == Enum.ProximityPromptInputType.Touch then
		CloneData.KeyCodeImage.Image = "rbxasset://textures/ui/Controls/TouchTapIcon.png"
	else
		local ButtonTextString = UserInputService:GetStringForKeyCode(self.Prompt.KeyboardKeyCode)

		local ButtonTextImage = KeyboardButtonImage[self.Prompt.KeyboardKeyCode]
		if ButtonTextImage == nil then
			ButtonTextImage = KeyboardButtonIconMapping[ButtonTextString]
		end

		if ButtonTextImage == nil then
			local KeyCodeMappedText = KeyCodeToTextMapping[self.Prompt.KeyboardKeyCode]
			if KeyCodeMappedText then
				ButtonTextString = KeyCodeMappedText
			end
		end

		if ButtonTextImage then
			CloneData.KeyCodeImage.Image = ButtonTextImage
		elseif ButtonTextString ~= nil and ButtonTextString ~= "" then
			CloneData.KeyCodeText.Text = ButtonTextString
		else
			error(
				"ProximityPrompt '"
					.. self.Prompt.Name
					.. "' has an unsupported keycode for rendering UI: "
					.. tostring(self.Prompt.KeyboardKeyCode)
			)
		end
	end

	CloneData.ActionUIText.Text = self.Prompt.ActionText
	CloneData.ObjectUIText.Text = self.Prompt.ObjectText

	CloneData.InvisiButtonConnect = CloneData.InvisibleButton.InputBegan:Connect(function(Input: InputObject)
		if Input.UserInputType == Enum.UserInputType.Touch or Input.UserInputType == Enum.UserInputType.MouseButton1 then
			self.Prompt:InputHoldBegin()
		end
	end)

	CloneData.ImageHoldEndedConnection = CloneData.InvisibleButton.InputEnded:Connect(function(Input: InputObject)
		if Input.UserInputType == Enum.UserInputType.Touch or Input.UserInputType == Enum.UserInputType.MouseButton1 then
			self.Prompt:InputHoldEnd()
		end
	end)

	if self.BillboardActive == false then
		Tween(CloneData.DesignPart, "CFrame", CloneData.DesignPart.CFrame * CFrame.new(0, 0, 0.95))
		Tween(CloneData.InstructionPart, "CFrame", CloneData.InstructionPart.CFrame * CFrame.new(0, 0, -0.95))
	end

	Tween(CloneData.DesignImage, "ImageTransparency", 0)
	Tween(CloneData.KeyCodeImage, "ImageTransparency", 0)

	Tween(CloneData.ActionUIText, "TextTransparency", 0)
	Tween(CloneData.ObjectUIText, "TextTransparency", 0)
	Tween(CloneData.KeyCodeText, "TextTransparency", 0)

	Tween(CloneData.InstructionBackground, "Transparency", 0.8)
	local FinalTween = Tween(CloneData.KeyCodeBackground, "Transparency", 0)
	FinalTween.Completed:Wait()
end
  1. Replace OnPromptHidden to go through all cloned prompts and handle them properly
function Hologram:OnPromptHidden(InputType: Enum.ProximityPromptInputType)
	for _, CloneData in ipairs(self.ClonedPrompts) do
		if CloneData.ClonedPrompt then
			if self.ShowBeam then
				BorderHighlight.Adornee = nil
				Tween(CloneData.TransparencyValue, "Value", 1)

				CloneData.Beam.Attachment1 = nil
				CloneData.BeamAttachment.Parent = CloneData.KeyCodePart
			end

			Tween(CloneData.DesignPart, "CFrame", CloneData.DesignPart.CFrame * CFrame.new(0, 0, -0.95))
			Tween(CloneData.InstructionPart, "CFrame", CloneData.InstructionPart.CFrame * CFrame.new(0, 0, 0.95))

			Tween(CloneData.DesignImage, "ImageTransparency", 1)
			Tween(CloneData.KeyCodeImage, "ImageTransparency", 1)

			Tween(CloneData.ActionUIText, "TextTransparency", 1)
			Tween(CloneData.ObjectUIText, "TextTransparency", 1)
			Tween(CloneData.KeyCodeText, "TextTransparency", 1)

			Tween(CloneData.InstructionBackground, "Transparency", 1)
			local FinalTween = Tween(CloneData.KeyCodeBackground, "Transparency", 1)
			FinalTween.Completed:Wait()

			self:DestroyPrompt(CloneData)
		end
	end
end
  1. Update PromptHolding to maek sure it works with multiple cloned prompts instead of just the original one.
function Hologram:PromptHolding()	
	for _, CloneData in ipairs(self.ClonedPrompts) do
		local Fill = TweenService:Create(CloneData.KeyCodeProgress, TweenInfo.new(self.HoldDuration, Enum.EasingStyle.Sine), {Size = UDim2.fromScale(1, 1)})
		Fill:Play()

		local Size = TweenService:Create(CloneData.DesignImage, TweenInfo.new(self.HoldDuration, Enum.EasingStyle.Sine), {Size = UDim2.fromScale(0.9, 0.9)})
		Size:Play()

		local function PromptHoldEnded()
			Fill:Cancel()
			Size:Cancel()

			Tween(CloneData.KeyCodeProgress, "Size", UDim2.fromScale(1, 0))
			Tween(CloneData.DesignImage, "Size", UDim2.fromScale(1, 1))

			if CloneData.HoldEndedConnection then
				CloneData.HoldEndedConnection:Disconnect()
			end
		end

		CloneData.HoldEndedConnection = self.Prompt.PromptButtonHoldEnded:Connect(PromptHoldEnded)
	end
end
  1. Finally, make sure DestroyPrompt fully cleans up everything related to cloned prompts, including UI elements and any lingering connections.
function Hologram:DestroyPrompt(CloneData)
	if not CloneData then return end

	for _, PromptUI in pairs(PlayerUI:GetChildren()) do
		if string.find(PromptUI.Name, "Hologram" .. self.PromptName .. CloneData.UUID) then
			PromptUI:Destroy()
		end
	end

	if CloneData.HoldEndedConnection then
		CloneData.HoldEndedConnection:Disconnect()
	end
	CloneData.BeamConnect:Disconnect()
	if CloneData.InvisiButtonConnect then
		CloneData.InvisiButtonConnect:Disconnect()
	end
	if CloneData.BillboardUI then
		CloneData.BillboardUI:Disconnect()
	end
	CloneData.ClonedPrompt:Destroy()

	for index, data in ipairs(self.ClonedPrompts) do
		if data == CloneData then
			table.remove(self.ClonedPrompts, index)
			break
		end
	end
end

So, if all was follows correctly, you should end up with this new module:

-- Written by Lightning_Game27

--// Services
local ProximityPromptService = game:GetService("ProximityPromptService")
local TweenService = game:GetService("TweenService")
local PlayerService = game:GetService("Players") 
local UserInputService = game:GetService("UserInputService")
local RunService = game:GetService("RunService")

--// Values
local Camera = workspace.CurrentCamera
local BorderHighlight = script:WaitForChild("Highlight")
local PromptTemplate = script:WaitForChild("PromptTemplate")

--// Prompt Images
local GamepadButtonImage = {
	[Enum.KeyCode.ButtonX] = "rbxasset://textures/ui/Controls/xboxX.png",
	[Enum.KeyCode.ButtonY] = "rbxasset://textures/ui/Controls/xboxY.png",
	[Enum.KeyCode.ButtonA] = "rbxasset://textures/ui/Controls/xboxA.png",
	[Enum.KeyCode.ButtonB] = "rbxasset://textures/ui/Controls/xboxB.png",
	[Enum.KeyCode.DPadLeft] = "rbxasset://textures/ui/Controls/dpadLeft.png",
	[Enum.KeyCode.DPadRight] = "rbxasset://textures/ui/Controls/dpadRight.png",
	[Enum.KeyCode.DPadUp] = "rbxasset://textures/ui/Controls/dpadUp.png",
	[Enum.KeyCode.DPadDown] = "rbxasset://textures/ui/Controls/dpadDown.png",
	[Enum.KeyCode.ButtonSelect] = "rbxasset://textures/ui/Controls/xboxView.png",
	[Enum.KeyCode.ButtonStart] = "rbxasset://textures/ui/Controls/xboxmenu.png",
	[Enum.KeyCode.ButtonL1] = "rbxasset://textures/ui/Controls/xboxLB.png",
	[Enum.KeyCode.ButtonR1] = "rbxasset://textures/ui/Controls/xboxRB.png",
	[Enum.KeyCode.ButtonL2] = "rbxasset://textures/ui/Controls/xboxLT.png",
	[Enum.KeyCode.ButtonR2] = "rbxasset://textures/ui/Controls/xboxRT.png",
	[Enum.KeyCode.ButtonL3] = "rbxasset://textures/ui/Controls/xboxLS.png",
	[Enum.KeyCode.ButtonR3] = "rbxasset://textures/ui/Controls/xboxRS.png",
	[Enum.KeyCode.Thumbstick1] = "rbxasset://textures/ui/Controls/xboxLSDirectional.png",
	[Enum.KeyCode.Thumbstick2] = "rbxasset://textures/ui/Controls/xboxRSDirectional.png",
}
local KeyboardButtonImage = {
	[Enum.KeyCode.Backspace] = "rbxasset://textures/ui/Controls/backspace.png",
	[Enum.KeyCode.Return] = "rbxasset://textures/ui/Controls/return.png",
	[Enum.KeyCode.LeftShift] = "rbxasset://textures/ui/Controls/shift.png",
	[Enum.KeyCode.RightShift] = "rbxasset://textures/ui/Controls/shift.png",
	[Enum.KeyCode.Tab] = "rbxasset://textures/ui/Controls/tab.png",
}
local KeyboardButtonIconMapping = {
	["'"] = "rbxasset://textures/ui/Controls/apostrophe.png",
	[","] = "rbxasset://textures/ui/Controls/comma.png",
	["`"] = "rbxasset://textures/ui/Controls/graveaccent.png",
	["."] = "rbxasset://textures/ui/Controls/period.png",
	[" "] = "rbxasset://textures/ui/Controls/spacebar.png",
}
local KeyCodeToTextMapping = {
	[Enum.KeyCode.LeftControl] = "Ctrl",
	[Enum.KeyCode.RightControl] = "Ctrl",
	[Enum.KeyCode.LeftAlt] = "Alt",
	[Enum.KeyCode.RightAlt] = "Alt",
	[Enum.KeyCode.F1] = "F1",
	[Enum.KeyCode.F2] = "F2",
	[Enum.KeyCode.F3] = "F3",
	[Enum.KeyCode.F4] = "F4",
	[Enum.KeyCode.F5] = "F5",
	[Enum.KeyCode.F6] = "F6",
	[Enum.KeyCode.F7] = "F7",
	[Enum.KeyCode.F8] = "F8",
	[Enum.KeyCode.F9] = "F9",
	[Enum.KeyCode.F10] = "F10",
	[Enum.KeyCode.F11] = "F11",
	[Enum.KeyCode.F12] = "F12",
}

--// Player
local Player = PlayerService.LocalPlayer
local PlayerUI = Player:WaitForChild("PlayerGui")
local Character = Player.Character or Player.CharacterAdded:Wait()

--// Common Functions
local function GetFaceNormals(Part: BasePart)
	local PartCF = Part.CFrame
	return {
		Back = -PartCF.LookVector,
		Front = PartCF.LookVector,
		Bottom = -PartCF.UpVector,
		Top = PartCF.UpVector,
		Left = PartCF.RightVector,
		Right = -PartCF.RightVector
	}
end

local function GetClosestFace(Part: BasePart)
	local FaceNormals = GetFaceNormals(Part)
	local ClosestFace = nil
	local HighestDotProduct = -math.huge

	local CameraPos = Camera.CFrame.Position

	for Face, Normal in pairs(FaceNormals) do
		local DirectionToPart = (CameraPos - Part.Position).Unit
		local DotProduct = Normal:Dot(DirectionToPart)

		if DotProduct > HighestDotProduct then
			HighestDotProduct = DotProduct
			ClosestFace = Face
		end
	end

	return ClosestFace
end

local Hologram = {}
Hologram.__index = Hologram

Hologram.GlobalTweenInfo = TweenInfo.new(0.5, Enum.EasingStyle.Exponential)
Hologram.Holograms = {}

local function Tween(Object, Property: string, Value)
	local NewTween = TweenService:Create(Object, Hologram.GlobalTweenInfo, {[Property] = Value})
	NewTween:Play()

	return NewTween
end

function Hologram.InitialiseGlobally(CheckTag: boolean?, ShowBeam: boolean?)
	CheckTag = CheckTag or false
	ShowBeam = ShowBeam or false

	for _, Prompt in pairs(workspace:GetDescendants()) do
		if Prompt:IsA("ProximityPrompt") then
			Hologram.New(Prompt, Vector3.zero, CheckTag, ShowBeam)
		end
	end
end

function Hologram.New(Prompt: ProximityPrompt, StudsOffset: Vector3?, CheckTag: boolean?, ShowBeam: boolean?)
	if CheckTag then
		if not Prompt:HasTag("Hologram") then
			error("ProximityPrompt does not have CollectionService Tag 'Hologram'.")
		end
	end

	Prompt.Style = Enum.ProximityPromptStyle.Custom

	local self = setmetatable({}, Hologram)
	self.ActionText = Prompt.ActionText
	self.ObjectText = Prompt.ObjectText
	self.PromptName = Prompt.Name
	self.KeyboardKeyCode = Prompt.KeyboardKeyCode
	self.HoldDuration = Prompt.HoldDuration
	self.Prompt = Prompt
	self.ShowBeam = ShowBeam or false
	self.StudsOffset = StudsOffset or Vector3.zero
	self.Colours = {
		Primary = Color3.fromRGB(0, 144, 255),
		Secondary = Color3.fromRGB(0, 0, 0),
		Tertiary = Color3.fromRGB(255, 255, 255)
	}
	self.UUID = os.clock() + math.random()
	self.BillboardActive = false

	if Prompt.Parent:IsA("BasePart") or Prompt.Parent:IsA("Attachment") then
		self.PromptParent = Prompt.Parent
	elseif Prompt.Parent:IsA("Model") and Prompt.Parent.PrimaryPart ~= nil then
		self.PromptParent = Prompt.Parent.PrimaryPart
	else
		error("Hologram requires ProximityPrompt to be parented to a BasePart, Attachment or Model with a PrimaryPart.")
	end

	Prompt.PromptShown:Connect(function(InputType: Enum.ProximityPromptInputType)
		self:OnPromptShown(InputType)
	end)

	Prompt.PromptHidden:Connect(function(InputType: Enum.ProximityPromptInputType)
		self:OnPromptHidden(InputType)
	end)

	Prompt.PromptButtonHoldBegan:Connect(function()
		self:PromptHolding()
	end)

	self.ClonedPrompts = {}

	Hologram.Holograms[Prompt] = self

	return self
end

function Hologram:CreatePrompt()
	local CloneUUID = os.clock() + math.random()

	local ClonedPrompt = PromptTemplate:Clone()

	local KeyCodePart = ClonedPrompt.KeyCode
	local KeyCodeUI = KeyCodePart.HologramKeyCodeUI
	local KeyCodeBackground = KeyCodeUI.Background
	local KeyCodeText = KeyCodeBackground.KeyCode
	local KeyCodeImage = KeyCodeBackground.KeyCodeImage
	local KeyCodeProgress = KeyCodeBackground.Progress

	local InstructionPart = ClonedPrompt.Instruction
	local InstructionUI = InstructionPart.HologramInstructionUI
	local InstructionBackground = InstructionUI.Background
	local ActionUIText = InstructionBackground.Action
	local ObjectUIText = InstructionBackground.Object
	local InvisibleButton = InstructionBackground.InvisiButton

	local DesignPart = ClonedPrompt.Design
	local DesignUI = DesignPart.HologramDesignUI
	local DesignImage = DesignUI.Image

	KeyCodeUI.Name = "Hologram" .. self.PromptName .. CloneUUID .. "KeyCodeUI"
	InstructionUI.Name = "Hologram" .. self.PromptName .. CloneUUID .. "InstructionUI"
	DesignUI.Name = "Hologram" .. self.PromptName .. CloneUUID .. "DesignUI"

	local Beam = KeyCodePart.Beam
	local BeamAttachment = KeyCodePart.Attachment
	local TransparencyValue = Beam:WaitForChild("TransparencyValue")

	ClonedPrompt.Name = "Hologram" .. self.PromptName .. CloneUUID
	ClonedPrompt.Parent = self.PromptParent

	KeyCodeUI.Parent = PlayerUI
	InstructionUI.Parent = PlayerUI
	DesignUI.Parent = PlayerUI

	local BeamConnect = TransparencyValue:GetPropertyChangedSignal("Value"):Connect(function()
		Beam.Transparency = NumberSequence.new(TransparencyValue.Value)
	end)

	local CloneData = {
		ClonedPrompt = ClonedPrompt,
		KeyCodePart = KeyCodePart,
		KeyCodeUI = KeyCodeUI,
		KeyCodeBackground = KeyCodeBackground,
		KeyCodeText = KeyCodeText,
		KeyCodeImage = KeyCodeImage,
		KeyCodeProgress = KeyCodeProgress,
		InstructionPart = InstructionPart,
		InstructionUI = InstructionUI,
		InstructionBackground = InstructionBackground,
		ActionUIText = ActionUIText,
		ObjectUIText = ObjectUIText,
		InvisibleButton = InvisibleButton,
		DesignPart = DesignPart,
		DesignUI = DesignUI,
		DesignImage = DesignImage,
		Beam = Beam,
		BeamAttachment = BeamAttachment,
		TransparencyValue = TransparencyValue,
		BeamConnect = BeamConnect,
		UUID = CloneUUID,
	}

	table.insert(self.ClonedPrompts, CloneData)

	return CloneData
end

function Hologram:DestroyPrompt(CloneData)
	if not CloneData then return end

	for _, PromptUI in pairs(PlayerUI:GetChildren()) do
		if string.find(PromptUI.Name, "Hologram" .. self.PromptName .. CloneData.UUID) then
			PromptUI:Destroy()
		end
	end

	if CloneData.HoldEndedConnection then
		CloneData.HoldEndedConnection:Disconnect()
	end
	CloneData.BeamConnect:Disconnect()
	if CloneData.InvisiButtonConnect then
		CloneData.InvisiButtonConnect:Disconnect()
	end
	if CloneData.BillboardUI then
		CloneData.BillboardUI:Disconnect()
	end
	CloneData.ClonedPrompt:Destroy()

	for index, data in ipairs(self.ClonedPrompts) do
		if data == CloneData then
			table.remove(self.ClonedPrompts, index)
			break
		end
	end
end

function Hologram:OnPromptShown(InputType: Enum.ProximityPromptInputType)
	local CloneData = self:CreatePrompt()
	if not CloneData.ClonedPrompt then return end

	local ClosestFace = GetClosestFace(self.PromptParent)
	local FacePosition, FaceNormal

	if self.BillboardActive == true then
		FacePosition = self.PromptParent.CFrame:PointToWorldSpace(self.StudsOffset)
		FaceNormal = (Camera.CFrame.Position - FacePosition).Unit
	else
		FaceNormal = GetFaceNormals(self.PromptParent)[ClosestFace]
		FacePosition = self.PromptParent.Position + (FaceNormal * (self.PromptParent.Size / 2)) + (FaceNormal * 1)
	end

	CloneData.KeyCodeText.TextTransparency = 1
	CloneData.ActionUIText.TextTransparency = 1
	CloneData.ObjectUIText.TextTransparency = 1

	CloneData.InstructionBackground.Transparency = 1
	CloneData.KeyCodeBackground.Transparency = 1

	CloneData.DesignImage.ImageTransparency = 1
	CloneData.KeyCodeImage.ImageTransparency = 1

	CloneData.DesignImage.ImageColor3 = self.Colours.Primary
	CloneData.KeyCodeProgress.BackgroundColor3 = self.Colours.Primary

	CloneData.InstructionBackground.BackgroundColor3 = self.Colours.Secondary
	CloneData.KeyCodeBackground.BackgroundColor3 = self.Colours.Secondary

	CloneData.KeyCodeText.TextColor3 = self.Colours.Tertiary
	CloneData.KeyCodeImage.ImageColor3 = self.Colours.Tertiary
	CloneData.ActionUIText.TextColor3 = self.Colours.Tertiary
	CloneData.ObjectUIText.TextColor3 = self.Colours.Tertiary

	if self.BillboardActive == true then
		CloneData.BillboardUI = RunService.RenderStepped:Connect(function()
			if not CloneData.ClonedPrompt or not CloneData.ClonedPrompt.PrimaryPart then return end

			local PartCFrame = self.PromptParent.CFrame
			local WorldOffset = PartCFrame:PointToWorldSpace(self.StudsOffset)
			local CameraPosition = Camera.CFrame.Position

			CloneData.ClonedPrompt:PivotTo(CFrame.lookAt(WorldOffset, CameraPosition, Vector3.new(0, 1, 0)))

			CloneData.DesignPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * 0.05)
			CloneData.InstructionPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * -0.05) + (CloneData.KeyCodePart.CFrame.RightVector * -2.845)
		end)
	else
		CloneData.ClonedPrompt:PivotTo(CFrame.new(FacePosition, FacePosition + FaceNormal))
		CloneData.ClonedPrompt:PivotTo(CloneData.ClonedPrompt.PrimaryPart.CFrame + (CloneData.ClonedPrompt.PrimaryPart.CFrame.LookVector * (self.StudsOffset.Z)) + (CloneData.ClonedPrompt.PrimaryPart.CFrame.RightVector * (self.StudsOffset.X)) + (CloneData.ClonedPrompt.PrimaryPart.CFrame.UpVector * (self.StudsOffset.Y)))

		CloneData.DesignPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * 1)
		CloneData.InstructionPart.CFrame = CloneData.KeyCodePart.CFrame + (CloneData.KeyCodePart.CFrame.LookVector * -1) + (CloneData.KeyCodePart.CFrame.RightVector * -2.845)
	end

	if self.ShowBeam == true then
		BorderHighlight.Adornee = self.PromptParent

		CloneData.Beam.Attachment1 = Character:WaitForChild("UpperTorso", 10).BodyFrontAttachment or Character:WaitForChild("Torso", 10).BodyFrontAttachment
		CloneData.BeamAttachment.Parent = self.PromptParent

		Tween(CloneData.TransparencyValue, "Value", 0)
	end

	if InputType == Enum.ProximityPromptInputType.Gamepad then
		if GamepadButtonImage[self.Prompt.GamepadKeyCode] then
			CloneData.KeyCodeImage.Image = GamepadButtonImage[self.Prompt.GamepadKeyCode]
		end
	elseif InputType == Enum.ProximityPromptInputType.Touch then
		CloneData.KeyCodeImage.Image = "rbxasset://textures/ui/Controls/TouchTapIcon.png"
	else
		local ButtonTextString = UserInputService:GetStringForKeyCode(self.Prompt.KeyboardKeyCode)

		local ButtonTextImage = KeyboardButtonImage[self.Prompt.KeyboardKeyCode]
		if ButtonTextImage == nil then
			ButtonTextImage = KeyboardButtonIconMapping[ButtonTextString]
		end

		if ButtonTextImage == nil then
			local KeyCodeMappedText = KeyCodeToTextMapping[self.Prompt.KeyboardKeyCode]
			if KeyCodeMappedText then
				ButtonTextString = KeyCodeMappedText
			end
		end

		if ButtonTextImage then
			CloneData.KeyCodeImage.Image = ButtonTextImage
		elseif ButtonTextString ~= nil and ButtonTextString ~= "" then
			CloneData.KeyCodeText.Text = ButtonTextString
		else
			error(
				"ProximityPrompt '"
					.. self.Prompt.Name
					.. "' has an unsupported keycode for rendering UI: "
					.. tostring(self.Prompt.KeyboardKeyCode)
			)
		end
	end

	CloneData.ActionUIText.Text = self.Prompt.ActionText
	CloneData.ObjectUIText.Text = self.Prompt.ObjectText

	CloneData.InvisiButtonConnect = CloneData.InvisibleButton.InputBegan:Connect(function(Input: InputObject)
		if Input.UserInputType == Enum.UserInputType.Touch or Input.UserInputType == Enum.UserInputType.MouseButton1 then
			self.Prompt:InputHoldBegin()
		end
	end)

	CloneData.ImageHoldEndedConnection = CloneData.InvisibleButton.InputEnded:Connect(function(Input: InputObject)
		if Input.UserInputType == Enum.UserInputType.Touch or Input.UserInputType == Enum.UserInputType.MouseButton1 then
			self.Prompt:InputHoldEnd()
		end
	end)

	if self.BillboardActive == false then
		Tween(CloneData.DesignPart, "CFrame", CloneData.DesignPart.CFrame * CFrame.new(0, 0, 0.95))
		Tween(CloneData.InstructionPart, "CFrame", CloneData.InstructionPart.CFrame * CFrame.new(0, 0, -0.95))
	end

	Tween(CloneData.DesignImage, "ImageTransparency", 0)
	Tween(CloneData.KeyCodeImage, "ImageTransparency", 0)

	Tween(CloneData.ActionUIText, "TextTransparency", 0)
	Tween(CloneData.ObjectUIText, "TextTransparency", 0)
	Tween(CloneData.KeyCodeText, "TextTransparency", 0)

	Tween(CloneData.InstructionBackground, "Transparency", 0.8)
	local FinalTween = Tween(CloneData.KeyCodeBackground, "Transparency", 0)
	FinalTween.Completed:Wait()
end

function Hologram:OnPromptHidden(InputType: Enum.ProximityPromptInputType)
	for _, CloneData in ipairs(self.ClonedPrompts) do
		if CloneData.ClonedPrompt then
			if self.ShowBeam then
				BorderHighlight.Adornee = nil
				Tween(CloneData.TransparencyValue, "Value", 1)

				CloneData.Beam.Attachment1 = nil
				CloneData.BeamAttachment.Parent = CloneData.KeyCodePart
			end

			Tween(CloneData.DesignPart, "CFrame", CloneData.DesignPart.CFrame * CFrame.new(0, 0, -0.95))
			Tween(CloneData.InstructionPart, "CFrame", CloneData.InstructionPart.CFrame * CFrame.new(0, 0, 0.95))

			Tween(CloneData.DesignImage, "ImageTransparency", 1)
			Tween(CloneData.KeyCodeImage, "ImageTransparency", 1)

			Tween(CloneData.ActionUIText, "TextTransparency", 1)
			Tween(CloneData.ObjectUIText, "TextTransparency", 1)
			Tween(CloneData.KeyCodeText, "TextTransparency", 1)

			Tween(CloneData.InstructionBackground, "Transparency", 1)
			local FinalTween = Tween(CloneData.KeyCodeBackground, "Transparency", 1)
			FinalTween.Completed:Wait()

			self:DestroyPrompt(CloneData)
		end
	end
end

function Hologram:PromptHolding()	
	for _, CloneData in ipairs(self.ClonedPrompts) do
		local Fill = TweenService:Create(CloneData.KeyCodeProgress, TweenInfo.new(self.HoldDuration, Enum.EasingStyle.Sine), {Size = UDim2.fromScale(1, 1)})
		Fill:Play()

		local Size = TweenService:Create(CloneData.DesignImage, TweenInfo.new(self.HoldDuration, Enum.EasingStyle.Sine), {Size = UDim2.fromScale(0.9, 0.9)})
		Size:Play()

		local function PromptHoldEnded()
			Fill:Cancel()
			Size:Cancel()

			Tween(CloneData.KeyCodeProgress, "Size", UDim2.fromScale(1, 0))
			Tween(CloneData.DesignImage, "Size", UDim2.fromScale(1, 1))

			if CloneData.HoldEndedConnection then
				CloneData.HoldEndedConnection:Disconnect()
			end
		end

		CloneData.HoldEndedConnection = self.Prompt.PromptButtonHoldEnded:Connect(PromptHoldEnded)
	end
end

--// Settings
function Hologram:SetAlwaysOnTop(isOnTop: boolean)
	self.KeyCodeUI.AlwaysOnTop = isOnTop
	self.InstructionUI.AlwaysOnTop = isOnTop
	self.DesignUI.AlwaysOnTop = isOnTop
end

function Hologram:SetPrimaryColour(Colour: Color3)
	self.Colours.Primary = Colour
end

function Hologram:SetSecondaryColour(Colour: Color3)
	self.Colours.Secondary = Colour
end

function Hologram:SetTertiaryColour(Colour: Color3)
	self.Colours.Tertiary = Colour
end

function Hologram:SetStudsOffset(StudsOffset: Vector3)
	self.StudsOffset = StudsOffset
end

function Hologram:SetBeam(ShowBeam: boolean)
	self.ShowBeam = ShowBeam
end

function Hologram:SetBillboardActive(isActive: boolean)
	self.BillboardActive = isActive
end

return Hologram

Hope this helps with the future of Hologram!

2 Likes

Thank you so much for the feedback & bug report. As for the auto switching, I’ll try to add it as an optional feature, just because I never intended it to auto switch.

As for the bug, I pushed a fix just now that uses a debounce to keep track. Now it should function smoothly. Thank you so much for your help, much appreciated!

Version 0.1.4

Fixes

  • Prompt should now completely fade away before reappearing. Thanks to @Aeresei for the bug report!

Now available on the Creator Store.

1 Like

Wow this is… Probably the most in depth reply ive ever seen. Im just impressed at how good this is!

Thank you for helping with this resource.

2 Likes

Just doing my part, I like to help those I can

2 Likes

Version 0.1.5

Upgrades

  • Animation for when HoldDuration is 0.
  • Added documentation for settings.

Fixes

  • AlwaysOnTop fixed.
  • Removed redundant code.

Now available on the Creator Store.

1 Like