A Guide to a Better Looking and More Realistic Day/Night Cycle

Changing FogColor and possibly FogEnd would be extremely helpful, but great tutorial nevertheless.

1 Like

I don’t think fog typically follows a daily cycle, so I did not include it.

Fog color would be fairly easy to do. It would use the same logic as colorShift_Top. You would need to declare a new “color list”.
Note: I have not calibrated any colors in these examples.

                 -- 1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19  20  21  22  23  24
local r2ColorList = {000,000,000,000,000,255,255,255,255,255,255,255,255,255,255,255,255,255,255,000,000,000,000,000}
local g2ColorList = {165,165,165,165,165,255,215,230,255,255,255,255,255,255,255,245,230,215,255,165,165,165,165,165}
local b2ColorList = {255,255,255,255,255,255,110,135,255,255,255,255,255,255,255,215,135,110,255,255,255,255,255,255}
local r2
local g2
local b2

And add another instance of the color lerping algorithim to the while loop (you don’t need to re declare pointer).

     r2=((rColorList[pointer%24+1]-r2ColorList[pointer])*(mam-pointer+1))+r2ColorList[pointer]
     g2=((gColorList[pointer%24+1]-g2ColorList[pointer])*(mam-pointer+1))+g2ColorList[pointer]
     b2=((bColorList[pointer%24+1]-b2ColorList[pointer])*(mam-pointer+1))+b2ColorList[pointer]
     game.Lighting.FogColor=Color3.fromRGB(r2,g2,b2)

As for FogStart/FogEnd, I can’t think of a reason why it might be changed on a daily schedule, so I can’t really think of a algorithm that would be useful for that. Presumably you could use a single channel of the colorShiftTop algorithim

                 -- 1   2   3   4   5   6   7   8   9   10  11  12  13  14  15  16  17  18  19  20  21  22  23  24
local fEndList = {000,000,000,000,000,255,255,255,255,255,255,255,255,255,255,255,255,255,255,000,000,000,000,000}
local fStartList = {165,165,165,165,165,255,215,230,255,255,255,255,255,255,255,245,230,215,255,165,165,165,165,165}

and in the loop

     game.Lighting.FogStart=((fStartList[pointer%24+1]-fStartList[pointer])*(mam-pointer+1))+fStartList[pointer]
     game.Lighting.FogEnd=((fEndList[pointer%24+1]-fEndList[pointer])*(mam-pointer+1))+fEndList[pointer]
1 Like

Your a monster bro, much obliged. EXTREMELY educational for me while at the same time providing such a good asset!

1 Like

Whoa! This thing is so advanced, look at those sine curves and math! I wonder how did you master at them? And also, I’m gonna do this tutorial.

Awesome! Adding Exposure-Compensation would be great as well considering that it could add more realistic detail towards the environment; a brighter morning, a way darker night. Or during specific times like during sunset it could raise for a specific period of time.

Personally I would use TweenService. Here’s a working day/night cycle using TweenService:

local cycleLength = 600

local tweenService = game:GetService('TweenService')
local lighting = game:GetService('Lighting')

local tweenInfo = TweenInfo.new(cycleLength / 24 * (24 - lighting.ClockTime), Enum.EasingStyle.Linear)
local tween = tweenService:Create(lighting, tweenInfo, {ClockTime = 24})

local function restart()
	tween:Destroy()
	lighting.ClockTime = 0
	tweenInfo = TweenInfo.new(cycleLength, Enum.EasingStyle.Linear, Enum.EasingDirection.Out, 999999)
	tween = tweenService:Create(lighting, tweenInfo, {ClockTime = 24})
	tween.Completed:Connect(restart)
	tween:Play()
end

tween.Completed:Connect(restart)
tween:Play()

cycleLength is the length of day + night in seconds, so 600 means day is 5 minutes long and night is 5 minutes long.

You can then use a LocalScript in PlayerScripts to manipulate extra lighting on the client, deterministically (perhaps during RenderStepped), so that even though each client is doing their own calculations, the server keeps the time of day in sync.

12 Likes

Your tutorial is awesome! It really improves the Day and Night cycle, but I just tested your final code and studio return a error when the first cycle is finished.

48: attempt to perform arithmetic (sub) on number and nil

--Line 48:

r=((rColorList[pointer%24+1]-rColorList[pointer])*(mam-pointer+1))+rColorList[pointer]

Besides that your tutorial is nice, and I will probally use this to my game! (Tested in a baseplate, soo its not really a error that is happening because of my game or anything related.)

Yea, I have had that happen in a few tests since I posted this tutorial. Seems to be tied to certain time shift values, but I am not entirely sure as the output is fine otherwise. If you ajust your time shift a bit it should work again.

Alright, I will try that, thanks for telling! You could adjust your thread tho saying that, would help people.

a very useful guide, I recommend everyone to give this a try.

I do the same thing in my game (albeit with a cosine piecewise function for whatever reason) and really recommend this method. Games that simply change the time of day and nothing else end up looking really dull. Days should be bright, and nights should be dark!

I never played around with the color shift properties though, and will definitely implement that into my system. Really cool!

To anyone else reading this, please ensure that you do these operations on the client. You will lag the server making frequent adjustments like this. My recommendation is that you use the server to sync a clock offset with all of the clients, and simply run it based off tick. If you have any questions feel free to ask me about my implementation

@shadowcalen1 Very nice explanation with the sine waves and great tutorial in general.
I’ve tested this out and worked NICELY good job bud!

1 Like

I tried using tween while making this. Worked out Well.

You can fix it by changing the following code:

--color shift top
pointer= math.ceil(mam)

to:

--color shift top
pointer= math.clamp(math.ceil(mam), 1, 24)

This change limits the pointer from 1 to 24 inclusive so that it will always index within the range of the three RBG arrays.

2 Likes

Great tutorial. Thanks a lot.

The bug was not caused by the timeShift value but caused by math.ceil(mam) which produces a 25. 25 is out of the range of the three RGB arrays as an index. A fix is to use map.clamp() to constrain the pointer from 1 to 24 inclusive.

Here is a refactored version of the code snippet:

local Lighting = game:GetService("Lighting")
local COLOR_LIST = {
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 110, Green = 215, Red = 255},
	{Blue = 135, Green = 230, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 215, Green = 245, Red = 255},
	{Blue = 135, Green = 230, Red = 255},
	{Blue = 110, Green = 215, Red = 255},
	{Blue = 255, Green = 255, Red = 255},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0},
	{Blue = 255, Green = 165, Red = 0}
}

local function startDayNightCycle(dayLength, tickLength)
	-- dayLength: how many real-life seconds an in-game day should last
	-- tickLength: real-life time interval in seconds for the engine to update the in-game time
	-- timeShift: in-game time in minutes for every tick happens in-game
	tickLength = tickLength or (1 / 30)
	local timeShift = (60 * 24) / (dayLength / tickLength)

	-- Constants
	local PI = math.pi
	local BRIGHTNESS = {Amplitude = 1, Offset = 2}
	local OUTDOOR_AMBIENT = {Amplitude = 20, Offset = 100}
	local SHADOW_SOFTNESS = {Amplitude = 0.2, Offset = 0.8}

	while true do
		local minutesAfterMidnight = Lighting:GetMinutesAfterMidnight() + timeShift
		local hoursAfterMidnight = minutesAfterMidnight / 60

		Lighting:SetMinutesAfterMidnight(minutesAfterMidnight)

		-- brightness
		Lighting.Brightness =
			BRIGHTNESS.Amplitude * math.cos(hoursAfterMidnight * (PI / 12) + PI) +
			BRIGHTNESS.Offset

		-- outdoor ambient
		local outdoorAmbientRGBValue =
			OUTDOOR_AMBIENT.Amplitude * math.cos(hoursAfterMidnight * (PI / 12) + PI) +
			OUTDOOR_AMBIENT.Offset

		Lighting.OutdoorAmbient =
			Color3.fromRGB(
			outdoorAmbientRGBValue,
			outdoorAmbientRGBValue,
			outdoorAmbientRGBValue
		)

		-- shadow softness
		Lighting.ShadowSoftness =
			SHADOW_SOFTNESS.Amplitude * math.cos(hoursAfterMidnight * (PI / 6)) +
			SHADOW_SOFTNESS.Offset

		-- color shift top
		local hourIndex = math.clamp(math.ceil(hoursAfterMidnight), 1, 24)

		local function getRGBs()
			local RGB_NAMES = {"Red", "Green", "Blue"}
			local out = {}

			for i = 1, #RGB_NAMES do
				local color = RGB_NAMES[i]
				local colorValue =
					((COLOR_LIST[hourIndex % 24 + 1][color] - COLOR_LIST[hourIndex][color]) *
					(hoursAfterMidnight - hourIndex + 1)) +
					COLOR_LIST[hourIndex][color]
				table.insert(out, colorValue)
			end
			return out
		end
		game.Lighting.ColorShift_Top = Color3.fromRGB(unpack(getRGBs()))

		-- ticks
		wait(tickLength)
	end
end

startDayNightCycle(30)
4 Likes

That certainly explains the bug. Forgot that ceil of 24 returns 25 so I ended up really confused as to why I was getting a output of 25 when the value should of been exactly 24. Going to update the post with your fix. Thanks

Pro Tip:
Use TweenService to tween Lighting.ClockTime instead of using :SetMinutesAfterMidnight() to achieve smoother shadow movement.

By using TweenService you can also easily increase your waitTime to 1 second or more and get huge performance gains. You only really need to tween the time of day since the shadows are the most noticeable visual change. All the other variables can simply be set once per iteration since their change is so minimal it doesn’t throw you off like the jittery shadows can.

For those interested, here’s an example that includes some of the other elements listed in the tutorial:

local Lighting = game:GetService("Lighting")
local TweenService = game:GetService("TweenService")

local timeShift = 50
local waitTime = 1

local amplitudeB = 1
local offsetB = 2

local amplitudeS = 0.2
local offsetS = 0.8

local pi = math.pi

while true do
	local mam = game.Lighting:GetMinutesAfterMidnight() + timeShift -- minutes after midnight
	local ham = mam/60 -- hours after midnight
	local wave = math.cos(ham * (pi / 12) + pi)
	local doubleWave =  math.cos(ham * (pi / 6))
	
	Lighting.Brightness = amplitudeB * wave + offsetB
	Lighting.ShadowSoftness = amplitudeS * doubleWave + offsetS
	
	local oaNum = 20 * wave + 80
	Lighting.OutdoorAmbient = Color3.fromRGB(oaNum, oaNum, oaNum)
	
	local ambientNum = 25 * (wave + doubleWave) + 50
	Lighting.Ambient = Color3.fromRGB(ambientNum, ambientNum, ambientNum)
	
	local tweenInfo = TweenInfo.new(waitTime, Enum.EasingStyle.Linear, Enum.EasingDirection.Out)
	local goal = {}
	goal.ClockTime = ham
	
	local tween = TweenService:Create(Lighting, tweenInfo, goal)
	tween:Play()
	
	wait(waitTime)
end

You may notice that I also added variables for calculating the sine waves which also helped improve performance a ton.

Great tutorial by the way. Very informative and well laid out. I’ll definitely recommend it to people looking into making a day/night script for their game :slight_smile:

17 Likes

Amazing! Thanks for making this. I wish roblox improved its lighting settings so that it automatically did this kind of thing depending on the time

2 Likes

Hm, I wonder if there is a way to tween the shadows, giving the smaller time-change increments a bit more of a smooth touch…

Regardless, wonderful explanation of the concept, I greatly appreciate it!

This is rather performance breaking

if you go fast

1 Like