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.

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

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

--[[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

Older Versions of this Script:

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.
36 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