Fake Volumetric Lighting w/ Beams (Proximity Transparency)

I’m writing this post to highlight a flaw in a cheap volumetric lighting technique commonly employed by many popular games; using Beams to signify the presence of particles in the air (whether that be fog, dust, smoke, etc.) as there would be in real life.
This video displays the method being used by some known games.

In real-world scenarios, the visible “glow” created by light interacting with aerosols in the air, diminishes in intensity from an observer’s viewpoint depending on their relative position. This reduction occurs because less of the illuminated volume is visible to the observer, which is NOT what you see occurring in the video.

I understand how relatively simple it is for major game developers to incorporate this feature. However, my main aim is to advocate for its implementation. Moreover, it serves as a valuable resource for individuals entirely unfamiliar with LUA as well.

Version 4 (6/11/24 @ 8:02 PM EST):

In this version, I’ve reemployed EndTransparency and StartTransparency, keeping the transparencies of the beams within the values already set for the beam upon being added to the table. However, if you do not like this change, simply replace StartTransparency.Value value with 0, and EndTransparency.Value with 1. In the foreseeing future, I plan to add support for Beams that do not face the camera, creating an infinite plane rather than an infinite line, which is quite simple. The main purpose of this update however was to make sure beams whose positions are always changing still have this take effect.

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Player = Players.LocalPlayer

local Character = Player.Character or Player.CharacterAdded:Wait()
local HumanoidRootPart = Character:WaitForChild("HumanoidRootPart")
local Camera = workspace.CurrentCamera

local MaxDistance = script:GetAttribute("MaxDistance") or 50 -- Maximum distance in studs at which beams gradually begin to disappear.
local MinDistance = script:GetAttribute("MinDistance") or 4 -- Lowest distance in studs at which beams are completely transparent.
local BasedOnCamera = script:GetAttribute("BasedOnCamera") or false -- Whether distance from the beams is based on the position of the camera or the player's character.

local VolumetricBeams = {}

local function DistanceToLine(Beam, Point, LinePoint1, LinePoint2)
	local Attachment0 = Beam.Attachment0
	local Attachment1 = Beam.Attachment1
	local LinePoint1 = Attachment0.WorldPosition
	local LinePoint2 = Attachment1.WorldPosition
	
	local LineDirection = (LinePoint2 - LinePoint1).Unit
	local PointToLine = Point - LinePoint1
	return (PointToLine - (PointToLine:Dot(LineDirection) * LineDirection)).Magnitude
end

local function OnDescendantAdded(Descendant)
	if Descendant:IsA("Beam") and Descendant.Name == "VolumetricBeam" then
		local Attachment0 = Descendant.Attachment0
		local Attachment1 = Descendant.Attachment1
		if Attachment0 and Attachment1 then
			local LinePoint1 = Attachment0.WorldPosition
			local LinePoint2 = Attachment1.WorldPosition
			VolumetricBeams[Descendant] = {InitialTransparency = Descendant.Transparency}
		end
	end
end

local function UpdateVolumetricBeamsTable()
	for _, Descendant in ipairs(workspace:GetDescendants()) do
		if not VolumetricBeams[Descendant] then
			OnDescendantAdded(Descendant)
		end
	end
end

local function UpdateVolumetricBeams()
	for Beam, Details in pairs(VolumetricBeams) do
		local PlayerPosition = BasedOnCamera and Camera.CFrame.Position or HumanoidRootPart.Position
		local DistanceToLine = DistanceToLine(Beam, PlayerPosition)
		local StartTransparency = Details.InitialTransparency.Keypoints[1]
		local EndTransparency = Details.InitialTransparency.Keypoints[#Details.InitialTransparency.Keypoints]
		local NormalizedTransparency = math.max(StartTransparency.Value, 1 - (DistanceToLine - MinDistance) / MaxDistance)
		Beam.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, NormalizedTransparency),
			NumberSequenceKeypoint.new(1, EndTransparency.Value),
		})
	end
end

RunService.RenderStepped:Connect(UpdateVolumetricBeams)
workspace.DescendantAdded:Connect(OnDescendantAdded)
UpdateVolumetricBeamsTable()

Version 3 (6/9/24 @ 1:45 AM EST):

In this version, I have finally addressed the concerns of being able to see the underpart of a beam without the hassles of suggestions provided previously. Originally the transparency of the beam was decided solely by the distance from the camera/character to an attachment point of the beam; this very clearly made the edge of the beam visible if it was observed at the wrong angle from a distance. This newest method resolves this issue by creating infinite imaginary lines that travel in both directions using the orientation of the line created by the two attachment points required for the beam. The distance is then calculated to any given point on the imaginary line-- in idea-- and the transparency is controlled by the distance to the closest point on the “imaginary line”. It’s a very cheap, adequate and good solution. Here is a video to the visual change.

Below is the code and how to use it (READ COMMENTS):

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Player = Players.LocalPlayer

local Character = Player.Character or Player.CharacterAdded:Wait()
local HumanoidRootPart = Character:WaitForChild("HumanoidRootPart")
local Camera = workspace.CurrentCamera

local MaxDistance = script:GetAttribute("MaxDistance") or 50 -- Maximum distance in studs at which beams gradually begin to disappear.
local MinDistance = script:GetAttribute("MinDistance") or 4 -- Lowest distance in studs at which beams are completely transparent.
local BasedOnCamera = script:GetAttribute("BasedOnCamera") or false -- Whether distance from the beams is based on the position of the camera or the player's character.

local VolumetricBeams = {}

local function DistanceToLine(Point, LinePoint1, LinePoint2)
	local LineDirection = (LinePoint2 - LinePoint1).unit
	local PointToLine = Point - LinePoint1
	return (PointToLine - (PointToLine:Dot(LineDirection) * LineDirection)).Magnitude
end

local function OnDescendantAdded(Descendant)
	if Descendant:IsA("Beam") and Descendant.Name == "VolumetricBeam" then
		local Attachment0 = Descendant.Attachment0
		local Attachment1 = Descendant.Attachment1
		if Attachment0 and Attachment1 then
			local LinePoint1 = Attachment0.WorldPosition
			local LinePoint2 = Attachment1.WorldPosition
			VolumetricBeams[Descendant] = {LinePoint1 = LinePoint1, LinePoint2 = LinePoint2, InitialTransparency = Descendant.Transparency}
		end
	end
end

local function UpdateVolumetricBeamsTable()
	for _, Descendant in ipairs(workspace:GetDescendants()) do
		if not VolumetricBeams[Descendant] then
			OnDescendantAdded(Descendant)
		end
	end
end

local function UpdateVolumetricBeams()
	for Beam, Details in pairs(VolumetricBeams) do
		local LinePoint1 = Details.LinePoint1
		local LinePoint2 = Details.LinePoint2
		local PlayerPosition = BasedOnCamera and Camera.CFrame.Position or HumanoidRootPart.Position
		local distanceToLine = DistanceToLine(PlayerPosition, LinePoint1, LinePoint2)
		local NormalizedTransparency = math.max(0, 1 - (distanceToLine - MinDistance) / MaxDistance)
		Beam.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, NormalizedTransparency),
			NumberSequenceKeypoint.new(1, 1),
		})
	end
end

RunService.RenderStepped:Connect(UpdateVolumetricBeams)
workspace.DescendantAdded:Connect(OnDescendantAdded)
UpdateVolumetricBeamsTable()

Older Versions of this Script:

Version 2 (2/21/24 @ 3:52 PM EST):

Below is the code and how to use it (READ COMMENTS):

--[[Useful details:
* This is a client controlled script.
* The transparency of beams are updated before every frame but this can be changed to a different event of your choice.
* The distance between the beam and the player's camera/character is the distance at which the player's camera/character is from the ancestor `BasePart` of the beam.
* The magnitude between the ancestor `BasePart` and player's camera/character does not account for the Y axis, though this can be easily adjusted.
* The transparency of beams is only adjusted by beam instances' who's names are "VolumetricBeam".
]]

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Player = Players.LocalPlayer

local Character = Player.Character or Player.CharacterAdded:Wait()
local HumanoidRootPart = Character:WaitForChild("HumanoidRootPart")
local Camera = workspace.CurrentCamera

local MaxDistance = 50 --Maximum distance in studs at which beams gradually begin to disappear.
local MinDistance = 4 --Lowest distance in studs at which beams are completely transparent.
local BasedOnCamera = false --Whether distance from the beams is based on the position of the camera or the player's character.

local VolumetricBeams = {}

local function OnDescendantAdded(Descendant: Instance)
	if Descendant:IsA("Beam") and Descendant.Name == "VolumetricBeam" then
		local SourcePart = Descendant:FindFirstAncestorWhichIsA("BasePart")
		if SourcePart then
			VolumetricBeams[Descendant] = {SourcePart = SourcePart, InitialTransparency = Descendant.Transparency}
		end
	end
end

local function OnDescendantRemoved(Descendant: Instance)
	VolumetricBeams[Descendant] = nil
end

local function UpdateVolumetricBeamsTable()
	for _, Descendant in workspace:GetDescendants() do
		if not VolumetricBeams[Descendant] then
			OnDescendantAdded(Descendant)
		end
	end
end

local function UpdateVolumetricBeams()
	for Beam, Details in VolumetricBeams do
		local NormalizedSourcePartPosition = Vector3.new(Details.SourcePart.Position.X, 0, Details.SourcePart.Position.Z)
		local NormalizedSubjectPosition
		if BasedOnCamera then
			NormalizedSubjectPosition = Vector3.new(Camera.CFrame.Position.X, 0, Camera.CFrame.Position.Z)
		else
			NormalizedSubjectPosition = Vector3.new(HumanoidRootPart.Position.X, 0, HumanoidRootPart.Position.Z)
		end
		local StartTransparency = Details.InitialTransparency.Keypoints[1]
		local EndTransparency = Details.InitialTransparency.Keypoints[#Details.InitialTransparency.Keypoints]
		local Magnitude = (NormalizedSubjectPosition - NormalizedSourcePartPosition).Magnitude - MinDistance
		local NormalizedValue = math.max(StartTransparency.Value, 1 - math.min(1, Magnitude / MaxDistance))
		Beam.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, NormalizedValue),
			NumberSequenceKeypoint.new(1, EndTransparency.Value),
		})
	end
end

RunService.RenderStepped:Connect(UpdateVolumetricBeams)
workspace.DescendantAdded:Connect(OnDescendantAdded)
workspace.DescendantRemoving:Connect(OnDescendantRemoved)

UpdateVolumetricBeamsTable()

adjustablebeamtransparency-RobloxStudio2024-02-1914-34-44-ezgif.com-video-to-gif-converter_1

Version 1:

--Client sided script
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Player = Players.LocalPlayer

local Character = Player.Character or Player.CharacterAdded:Wait()
local HumanoidRootPart = Character:WaitForChild("HumanoidRootPart")
local Camera = workspace.CurrentCamera

local MaxDistance = 50 --Maximum distance in studs at which beams gradually begin to disappear.
local MinDistance = 4 --Lowest distance in studs at which beams are completely transparent.
local BasedOnCamera = false --Whether distance from the beams is based on the position of the camera or the player's character.

function UpdateBeamTransparency()
	for _, Object in workspace:GetDescendants() do
		if Object:IsA("Beam") and Object.Name == "VolumetricBeam" then --Transparency will only be adjusted to beams named "VolumetricBeam".
			local SourcePart = Object:FindFirstAncestorWhichIsA("BasePart")
			local NormalizedSourcePartPosition = Vector3.new(SourcePart.Position.X, 0, SourcePart.Position.Z)
			local NormalizedSubjectPosition
			if BasedOnCamera then
				NormalizedSubjectPosition = Vector3.new(Camera.CFrame.Position.X, 0, Camera.CFrame.Position.Z)
			else
				NormalizedSubjectPosition = Vector3.new(HumanoidRootPart.Position.X, 0, HumanoidRootPart.Position.Z)
			end
			local Magnitude = (NormalizedSubjectPosition - NormalizedSourcePartPosition).Magnitude - MinDistance
			local NormalizedValue = 1 - math.min(1, Magnitude / MaxDistance)
			Object.Transparency = NumberSequence.new({
				NumberSequenceKeypoint.new(0, NormalizedValue),
				NumberSequenceKeypoint.new(1, 1),
			})
		end
	end
end

RunService.RenderStepped:Connect(UpdateBeamTransparency) --Updated before every frame. However, you can connect it to a different event like only when the camera or player's character is actively moving.
40 Likes

This has horrid optimization. Consider a solution like Volumika’s

3 Likes

Sure, a friend of mine has already spent money on that system. It also has utterly terrible customization, and has a lack of precision. Care to tell me how this is poorly optimized? Also Volumika is not at all performant. Game developers would likely not use Volumika unless they’re attempting to make a hyper realistic game, which is not everyone’s objective. I state clearly this is a nice addition to fake volumetrics.

5 Likes

Looping through possibly tens of thousands of instances every frame & doing checks on them is significantly less optimized than GPU-based lighting calculations from Roblox’s built-in voxel grid. Volumika also has more customization when the game is adjusted to it and it’s used correctly. The minuscule settings on it make large differences. The lack of precision is a result of Roblox particle limitation, and Volumika isn’t “hyper-realistic”.

3 Likes

This remains an inexpensive approach to a basic volumetric technique that simple games are using. While updating each frame may not be the most efficient, it can be readily adapted to varying intervals. The performance demands of looping through numerous instances are not as high as you anticipate. Regardless, even if they were, individuals not aiming to recreate the entire Earth at a 1:1 scale can still utilize this method seamlessly.

5 Likes

I’m confused. Can you post a video of before and after so I can clearly tell the difference.

1 Like

Click on the video hyperlink, and then also take a look at the GIF.

1 Like

Is the feature that in the gif when you are underneath the lamp the beam goes away instead of doing that weird turning thing in the video?

1 Like

Also why are people complaining about performance? couldn’t you just hook this up to a CollectionService tag so that you don’t have to loop through the whole workspace every frame. And if you have a lot of beams you can only update the ones near a player.

2 Likes

Yes there are ways further to optimize this, but I can leave that up to the person who is need of further optimization, I’m mainly advocating for this to happen in games.

3 Likes

Okay thank you this is really useful to know and keep in mind!

I was testing it out and see the free flashlight in the toolbox has the same problem as well.


image

1 Like

I have acknowledged it’s less effective with beams pointing horizontally, however you can reposition the ancestor BasePart and adjust the Min and Max distance values to accommodate best for it.

2 Likes

If I come up with a very good fix or something for it, I’ll give it a shot. Also let me know how it works out or if you got any questions.

2 Likes

To solve the performance hit from iterating through so many (unnececary) instances at once, you can force only tagged instances of beams to have this effect (thus narrowing it down to possibly tens of instances instead of thousands).

Although I’m not too experienced in how these things would work, I do have a theoretical way of fixing the issue @batteryday forwarded. The beam could be hidden in replacement of a billboard gui at the source location when the camera intersects with the beam and vice versa.

2 Likes

To be clear, the image of the flashlight is how it comes out of the toolbox by default not after running the script.

1 Like

I could consider trying something like this. Doesn’t sound like a bad idea (the billboards)

1 Like

You should save the instances named “VolumetricBeam” so you don’t need to loop through every descendant every frame.

Optimized code:

--Client sided script
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Player = Players.LocalPlayer

local Character = Player.Character or Player.CharacterAdded:Wait()
local HumanoidRootPart = Character:WaitForChild("HumanoidRootPart")
local Camera = workspace.CurrentCamera

local MaxDistance = 50 --Maximum distance in studs at which beams gradually begin to disappear.
local MinDistance = 4 --Lowest distance in studs at which beams are completely transparent.
local BasedOnCamera = false --Whether distance from the beams is based on the position of the camera or the player's character.

local volumetricBeams = {}

local function onDescendantAdded(descendant)
	if descendant:IsA("Beam") and descendant.Name == "VolumetricBeam" then
		local sourcePart = descendant:FindFirstAncestorWhichIsA("BasePart")
		
		if sourcePart then
			volumetricBeams[descendant] = sourcePart
		end
	end
end

workspace.DescendantAdded:Connect(onDescendantAdded)

workspace.DescendantRemoving:Connect(function(descendant)
	volumetricBeams[descendant] = nil
end)

for i, descendant in workspace:GetDescendants() do
	onDescendantAdded(descendant)
end

local function UpdateBeamTransparency()
	for Object, SourcePart in volumetricBeams do
		local NormalizedSourcePartPosition = Vector3.new(SourcePart.Position.X, 0, SourcePart.Position.Z)
		
		local NormalizedSubjectPosition = BasedOnCamera and Vector3.new(Camera.CFrame.Position.X, 0, Camera.CFrame.Position.Z) 
			or Vector3.new(HumanoidRootPart.Position.X, 0, HumanoidRootPart.Position.Z)

		local Magnitude = (NormalizedSubjectPosition - NormalizedSourcePartPosition).Magnitude - MinDistance
		local NormalizedValue = 1 - math.min(1, Magnitude / MaxDistance)
		
		Object.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, NormalizedValue),
			NumberSequenceKeypoint.new(1, 1),
		})
	end
end

RunService.RenderStepped:Connect(UpdateBeamTransparency) --Updated before every frame. However, you can connect it to a different event like only when the camera or player's character is actively moving.

This looks very nice though, good work.

2 Likes

Yes, this is not a bad solution to the previous concern. I encourage anybody else here to use this method you’ve presented. But if it’s not already obvious it doesn’t need to be updated every single frame anyway, it’s just for the example. This is also an extremely great method for saving the initial transparency of the beams if you need to restore it later, or you want the transparency to be at it’s lowest value, x instead of 0.

2 Likes

Here is a more optimized and new version of the original design. It includes @Katrist’s optimizations of adding the beam instances to their own table to avoid looping through potentially thousands of instances in the workspace. Additionally, it now remembers the initial transparency of the beam before the script modifies it.

local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local Player = Players.LocalPlayer

local Character = Player.Character or Player.CharacterAdded:Wait()
local HumanoidRootPart = Character:WaitForChild("HumanoidRootPart")
local Camera = workspace.CurrentCamera

local MaxDistance = 50 --Maximum distance in studs at which beams gradually begin to disappear.
local MinDistance = 4 --Lowest distance in studs at which beams are completely transparent.
local BasedOnCamera = false --Whether distance from the beams is based on the position of the camera or the player's character.

local VolumetricBeams = {}

local function OnDescendantAdded(Descendant: Instance)
	if Descendant:IsA("Beam") and Descendant.Name == "VolumetricBeam" then
		local SourcePart = Descendant:FindFirstAncestorWhichIsA("BasePart")
		if SourcePart then
			VolumetricBeams[Descendant] = {SourcePart = SourcePart, InitialTransparency = Descendant.Transparency}
		end
	end
end

local function OnDescendantRemoved(Descendant: Instance)
	VolumetricBeams[Descendant] = nil
end

local function UpdateVolumetricBeamsTable()
	for _, Descendant in workspace:GetDescendants() do
		if not VolumetricBeams[Descendant] then
			OnDescendantAdded(Descendant)
		end
	end
end

local function UpdateVolumetricBeams()
	for Beam, Details in VolumetricBeams do
		local NormalizedSourcePartPosition = Vector3.new(Details.SourcePart.Position.X, 0, Details.SourcePart.Position.Z)
		local NormalizedSubjectPosition
		if BasedOnCamera then
			NormalizedSubjectPosition = Vector3.new(Camera.CFrame.Position.X, 0, Camera.CFrame.Position.Z)
		else
			NormalizedSubjectPosition = Vector3.new(HumanoidRootPart.Position.X, 0, HumanoidRootPart.Position.Z)
		end
		local StartTransparency = Details.InitialTransparency.Keypoints[1]
		local EndTransparency = Details.InitialTransparency.Keypoints[#Details.InitialTransparency.Keypoints]
		local Magnitude = (NormalizedSubjectPosition - NormalizedSourcePartPosition).Magnitude - MinDistance
		local NormalizedValue = math.max(StartTransparency.Value, 1 - math.min(1, Magnitude / MaxDistance))
		Beam.Transparency = NumberSequence.new({
			NumberSequenceKeypoint.new(0, NormalizedValue),
			NumberSequenceKeypoint.new(1, EndTransparency.Value),
		})
	end
end

RunService.RenderStepped:Connect(UpdateVolumetricBeams)
workspace.DescendantAdded:Connect(OnDescendantAdded)
workspace.DescendantRemoving:Connect(OnDescendantRemoved)

UpdateVolumetricBeamsTable()

I may completely rewrite my original post in the future if additional features are added or come along. Considering methods of preventing the beams from looking funny when pointing horizontally. Possibly @VegetationBush’s suggestion.

3 Likes

… Or you could just use collectionservice

3 Likes