Light detection revamp - Optimized and more accurate

It’s been awhile since i have made any changes to the Light detection module i made.

It was working to some extended but after a while i found numerous bugs and frame drops due to the use of unoptimized functions and events.

I did a few updates as you some of you know. For example i made LightDetectionV2 which did some minor optimizations and added a few new methods. The problem is there is no documentation on how it works (since the base was rescripted). After awhile it broke due to roblox updating.

I was still not satisifed with the results so i chose to make ANOTHER version which at the end i didnt publish just because of how messy it was.

A few months Later and i chose to REVAMP it.

The new version, compared to the latest one (Version 3) is WAY faster
image

The changes i made are changing the math from linear algebra to using solid geometry.

it works just as the first version with just 1 new method

Anyway enough talking

Code
--!nonstrict
local module = {}

local Lighting = game.Lighting
local Run_Service = game:GetService("RunService")

local function SpotLight(x : Vector3,dir : Vector3,p : Vector3,ang : number,range : number) -- Cone
	ang = ang > 0 and ang or 1
	local A = CFrame.lookAt(x,x + dir) * CFrame.Angles(0,math.rad(ang/2),0) * CFrame.new(0,0,-range)
	local B = CFrame.lookAt(x,x + dir) * CFrame.Angles(0,math.rad(-ang/2),0) * CFrame.new(0,0,-range)
	
	local r = (A.Position - B.Position).Magnitude/2
	local midPoint = A:Lerp(B,0.5).Position
	local h = (midPoint - x).Magnitude
	
	local dist = (p - x):Dot(dir)
	local cone_radius = (dist / h) * r
	
	local dist2 = ((p - x) - dist * dir).Magnitude
	
	return (dist2 < cone_radius) 
end

local function Side4D(p1,p2,p3,p4,p)
	local dp = (p1+p2+p3+p4)/4

	local up = (p2 - p1)
	local right = (p2 - p4)

	local dir = up:Cross(right)

	local dir2 = (dp - p)
	local dot = dir:Dot(dir2)

	return dot >= 1
end 
    
local function SurfaceLight(p1,p2,p3,p4,p5,p6,p7,p8,p)
	local x,x1,x2,x3,x4,x5 = Side4D(p1,p2,p3,p4,p) ,Side4D(p1,p5,p6,p2,p) ,Side4D(p2,p6,p7,p3,p) ,Side4D(p3,p7,p8,p4,p) ,Side4D(p4,p8,p5,p1,p) ,Side4D(p5,p8,p7,p6,p) 

	return x and x1 and x2 and x3 and x4 and x5
end

local function PointLight(x,p,r)
	return x:Dot(p) < r^2
end

local function CheckObfuscated(t)
	for i,part : BasePart in t do
		if part.CastShadow == false then
			table.remove(t,i)
		end
		if part.Transparency >= 0.5 then
			table.remove(t,i)
		end
	end
	
	return t
end

local function GetVectorFromNormal(BasePart : BasePart | Attachment,Normal)
	local Dirs = {}
	
	if BasePart:IsA("BasePart") then
		Dirs = {
			[Enum.NormalId.Top] = BasePart.CFrame.UpVector,
			[Enum.NormalId.Bottom] = -BasePart.CFrame.UpVector,
			[Enum.NormalId.Right] = BasePart.CFrame.RightVector,
			[Enum.NormalId.Left] = -BasePart.CFrame.RightVector,
			[Enum.NormalId.Front] = BasePart.CFrame.LookVector,
			[Enum.NormalId.Back] = -BasePart.CFrame.LookVector,
		}
	else
		Dirs = {
			[Enum.NormalId.Top] = BasePart.WorldCFrame.UpVector,
			[Enum.NormalId.Bottom] = -BasePart.WorldCFrame.UpVector,
			[Enum.NormalId.Right] = BasePart.WorldCFrame.RightVector,
			[Enum.NormalId.Left] = -BasePart.WorldCFrame.RightVector,
			[Enum.NormalId.Front] = BasePart.WorldCFrame.LookVector ,
			[Enum.NormalId.Back] = -BasePart.WorldCFrame.LookVector,
		}
	end
	
	return Dirs[Normal]
end

local function Side(v1 : Vector3,v2 : Vector3,v3 : Vector3,v4 : Vector3,p : Vector3)
	local normal =  (v2 - v1):Cross(v3 - v1)
	local dotV4 = normal:Dot(v4 - v1)
	local dotP = normal:Dot(p - v1)
	return math.sign(dotV4) == math.sign(dotP)
end

local function SurfaceLightAtt(v1 : Vector3,v2 : Vector3,v3 : Vector3,v4 : Vector3,p : Vector3)
	return Side(v1, v2, v3, v4, p) and
		Side(v2, v3, v4, v1, p) and
		Side(v3, v4, v1, v2, p) and
		Side(v4, v1, v2, v3, p)
end

local OVP = OverlapParams.new()
OVP.FilterType = Enum.RaycastFilterType.Blacklist

local function CheckInSunlight(Point : Vector3)
	local sunlightDir = Lighting:GetSunDirection()
	local sp = Point + sunlightDir * 500
	local scf = CFrame.lookAt(sp,sp + sunlightDir)
	local ssize = Vector3.new(1,1,1000)
	
	local Det = workspace:GetPartBoundsInBox(CFrame.new(Point),Vector3.one/10)[1]
	OVP.FilterDescendantsInstances = {Det}
	
	local Sunlight = if Lighting.GlobalShadows then CheckObfuscated(workspace:GetPartBoundsInBox(scf,ssize,OVP)) else {}
	
	if Sunlight and 18 > Lighting.ClockTime and Lighting.ClockTime > 8 then
		return true
	end
	
	return false
end

local AllLights : {Light} = {}

workspace.DescendantAdded:Connect(function(child : Light)
	if child:IsA("Light") then
		table.insert(AllLights,child)
	end
end)

for _,v : Light in workspace:GetDescendants() do
	if v:IsA("Light") then
		table.insert(AllLights,v)
	end
end

export type LightObject = {
	LightLevel: number,
	Lights: {Light},
	Sunlight: boolean,
	InLight: boolean
}

function module:DetectLightAtObject(Object: BasePart)
	local self = {
		Part = Object,
	}
	
	function self:Update()
		local info =  module:DetectLightAtPoint(Object.Position)
		return info
	end
	
	return self
end

function module:DetectLightAtPoint(Point: Vector3)
	local Detected = {
		Lights = {},
		Sunlight = false,
		InLight = false,
		LightLevel = 0,
	} :: LightObject
	
	local SunLight = CheckInSunlight(Point)
	
	if SunLight then
		Detected.Sunlight = true
		Detected.LightLevel = 15
	end
	
	for _,Light in AllLights do
		local LightParent = Light.Parent
		local LightPos = LightParent:IsA("BasePart") and LightParent.Position or (LightParent:IsA("Attachment") and LightParent.WorldPosition or nil)
		
		if not LightPos then continue end
		if not Light.Enabled then continue end
		
		local Brightness = Light.Brightness/2
		local range = Light.Range
		
		local magnitude = (Point - LightPos).Magnitude
		local unit = (LightPos - Point).Unit
		
		local p = Point:Lerp(LightPos,0.5)
		local cf = CFrame.lookAt(p,p + unit)
		local size = Vector3.new(1,1,magnitude)
		
		local Det = workspace:GetPartBoundsInBox(CFrame.new(Point),Vector3.one/10)[1]
		OVP.FilterDescendantsInstances = {Det,LightParent}
		
		local obfuscated = if Light.Shadows then CheckObfuscated(workspace:GetPartBoundsInBox(cf,size,OVP)) else {}
		
		if #obfuscated > 0 then continue end
		
		if Light:IsA("PointLight") then
			local InRadius = PointLight(LightPos,Point,Light.Range)
			
			if InRadius then
				table.insert(Detected.Lights,Light)
				Detected.LightLevel = math.min((Detected.LightLevel + (range - magnitude) * (1+Brightness/magnitude))*2,15)
			end
		end
		
		if Light:IsA("SpotLight") then
			local dir = GetVectorFromNormal(LightParent,Light.Face)
			local InCone = SpotLight(LightPos,dir,Point,Light.Angle,range)
			
			if InCone and magnitude  < range - range/10 then
				table.insert(Detected.Lights,Light)
				Detected.LightLevel = math.min((Detected.LightLevel + (range - magnitude) * (1+Brightness/magnitude))*2,15)
			end
		end
		
		if Light:IsA("SurfaceLight") then
			local dir = LightParent.CFrame * CFrame.new(Vector3.fromNormalId(Light.Face)) 
			
			if LightParent:IsA("BasePart") then	
				local ParentSize = LightParent.Size/2	
				
				local deg = Light.Angle
				local ang = math.rad(deg)
				
				local p1 = (dir * CFrame.new(-ParentSize.X,-ParentSize.Y,0)).Position
				local p2 = (dir * CFrame.new(ParentSize.X,-ParentSize.Y,0)).Position
				
				local p3 = (CFrame.new(p2,p2 + dir.LookVector) * CFrame.new(ang*10,0,-range) * CFrame.Angles(0,-ang/2,0)) * CFrame.new(0,-range*ang/2+math.max(0,deg - 45)/30,0)
				local p4 = (CFrame.new(p1,p1 + dir.LookVector) * CFrame.new(-ang*10,0,-range) * CFrame.Angles(0,ang/2,0)) * CFrame.new(0,-range*ang/2+math.max(0,deg - 45)/30,0)
				
				local p5 = (dir * CFrame.new(-ParentSize.X,ParentSize.Y,0)).Position
				local p6 = (dir * CFrame.new(ParentSize.X,ParentSize.Y,0)).Position
				
				local p7 = p3.Position * Vector3.new(1,-1,1) + Vector3.new(0,p6.Y,0)
				local p8 = p4.Position * Vector3.new(1,-1,1) + Vector3.new(0,p5.Y,0)
								
				local InsideCube = SurfaceLight(p1,p2,p3.Position,p4.Position,p5,p6,p7,p8,Point)
				
				if InsideCube and magnitude < range - range/10 then
					table.insert(Detected.Lights,Light)
					Detected.LightLevel = math.min((Detected.LightLevel + (range - magnitude) * (1+Brightness/magnitude))*2,15)
				end
			else
				local dir = GetVectorFromNormal(LightParent,Light.Face)
				local InCone = SpotLight(LightPos,dir,Point,Light.Angle,range)

				if InCone and magnitude  < range - range/10 then
					table.insert(Detected.Lights,Light)
					Detected.LightLevel = math.min((Detected.LightLevel + (range - magnitude) * (1+Brightness/magnitude))*2,15)
				end
			end
		end
	end
	
	Detected.InLight = #Detected.Lights > 0 or Detected.Sunlight
	
	return Detected
end

return module
Code Example
local LightDetection = require(game.ReplicatedStorage.LightDetectionRevamp)

local Data = LightDetection:DetectLightAtPoint(Vector3.new(0,10,0)) -- returns table with contents
--[[
{
	LightLevel: number,
	Lights: {Light},
	Sunlight: boolean,
	InLight: boolean
}
--]]
-----------------------------------
local LightDetector = LightDetection:DetectLightAtObject(workspace.BasePlate) -- returns table with method :Update()
LightDetector:Update() -- returns the same thing as LightDetection:DetectLightAtPoint()

Link to the module: LightDetectionRevamp

and videos


The math can be optimized so feel free giving suggestions or just straight up making your own version of this module. I havent really tested it so please report here if there are any bugs you encontered

104 Likes

This is pretty cool and all but… How can a developer use it?..

2 Likes

I gave a code example here, you need either a Vector3 or a BasePart to detect the light on.
Thats all, if you want to customize anything you will have to dig in the module.

6 Likes

I feel like this can be used for automatic vehicle lights. I might check it out.

No, I meant in a game. Like for what

Hmmm not quite sure of that one

An example of using this is to make a sighting feature for stealth (horror), or hide and seek games.
Op could have said this at first place to make you understand more but whatever.

Or a game like Lightmatter, where if your not in light you die.

2 Likes

Honestly, the opposite of that would be more realistic. That’s a good game idea.

1 Like

This doesn’t work for me, the part is always green, even when the part is not in light nor in the shadows.

local debugPart = workspace:WaitForChild("DebugPart")
local origin = debugPart.Position
local radius = 5
local m = require(script.ModuleScript)
local detection = m:DetectLightAtObject(debugPart)
RunService.Heartbeat:Connect(function(deltaTime)
	debug.profilebegin("Move")
	local now = time() * 5
	local pos = origin + Vector3.new(math.cos(now) * radius, 0, math.sin(now) * radius)
	debugPart:PivotTo(CFrame.new(pos))
	debug.profileend() -- Move
	
	debug.profilebegin("Scan")
	local metadata = detection:Update()
	debugPart.Color = if metadata.InLight or metadata.Sunlight then Color3.new(0, 1, 0) else Color3.new(1, 0, 0)
	debug.profileend() -- Scan
end)

I also printed the data returned, InLight and Sunlight are also both true
The video attached below, is where the detection also seems to work only in the dark, and it only turns green on the right side of the light from where it’s facing.

Also, when a light is destroyed, errors occur.
Tl;dr every light besides SurfaceLight work fine, and sunlight detections do not work correctly.

3 Likes

Dont copy the code from the dev forum, there was a bug that i found a few days after i made this post. The working version is the module

1 Like

Thanks for letting me know, will try

How would i use the module ? i am trying to do a flashligt thats scares away monsters

i only neeed to know how to detect the light

How would I go about using this module?
Say if I wanted to detect if a player is in the light or not.

1 Like

Whoa! This is cool! This would be interesting for something similar to what Minecraft does with mob spawns.

Oh that’s a cool idea!

I will look into if I can use this in a game I am working on! Keep up the good work!

Like @crazygamespp said, how exactly do you detect the light?

Uhh, I’m a but confused. So for this:

local LightDetector = LightDetection:DetectLightAtObject(workspace.BasePlate)
LightDetector:Update()

How do I get the light level from that?

And also it doesn’t work at all, I have a part absolutely drenched in light, and the light level is always either 15 or 0.

Most definitely for an NPC, more specifically for a horror game or a story-driven FPS. Let’s say for example a player is hiding from a hostile NPC and suddenly the player walks into the light, logically he would be more visible. Instead of just FOV checks for the NPC, we can now include lighting for more visibility configuration. It’s pretty neat.

i was playing around with the module a few days ago, i might’ve broken it. Get the model from the toolbox
it should work now

Well there’s a new problem with the code. On line 86 it tries to add Point and sunlightDir, but the console was saying that it was trying to add a vector3 and a table, so I printed the point, and it turns out it’s a table. What do I do with point now?

Edit: The first original point from calling the module function from the local script appears to be a table, but in the local script I am calling the module function with a vector3.

Edit: I made a small edit that returns the point as soon as the function is called, and here is the actual value called versus what the function claims what is called.

	local vec = Vector3.new(0,10,0)
	print(vec)
	local data = LightDetection:DetectLightAtPoint(vec)
	
	print(table.unpack(data))
  15:14:24.633  0, 10, 0  -  Client - LocalScript:25
  15:14:24.633    -  Client - LocalScript:28

nothing I guess?

Edit: roblox be tweakin
I literally just made a module script that its only function is to return what is inputted, and it returned nothing / a table of nothing. What is going on???

Edit: what

local module = {}

function module.DetectLightAtPoint(Point)
	print(table.unpack(Point))
	return Point
end

return module

local mds = require(game.ReplicatedStorage.ModuleScript)

local dt = mds:DetectLightAtPoint(vec)
  15:22:27.524  0, 10, 0  -  Client - LocalScript:25
  15:22:27.525    -  Client - ModuleScript:4

Edit: I put the entire module script onto the local script and called the function from there, and I managed it to print something, but once again, it’s binary. It’s either 15 or 0. No in-between. Doesn’t matter if I should be blinded or not, it’s 15 or 0.