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()
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.