Day/Night Cycle in Unity

alt text

Day / Night Cycle

Lighting is an important part of visual communication. To improve upon our athmosphere and visual identity, we developed a dynamic lighting system. This lighting system gives us the ability to set a time of day and automatically control the light position, shadows, light color and skybox textures. Here's how we did it:

Skybox shader with blending

The lighting system consist of a skybox and a script that controls it. The skybox we use is a modified version of the Unity skybox shader where we can blend between 2 different skyboxes.

To control the blending we added a shader property called _Blend that is a range between 0 and 1, where 0 means show only first skybox and 1 means show only second skybox.

_Blend ("Blend", Range(0.0, 1.0)) = 0

A second skybox was added to the shader by duplicating the texture inputs so that the skyboxes have 6 texture inputs each.

[NoScaleOffset] _FrontTex0 ("Front [+Z]   (HDR)", 2D) = "grey" {}
[NoScaleOffset] _FrontTex1 ("Front [+Z]   (HDR)", 2D) = "grey" {}

Since we now have 2 skyboxes, we add texcoords for the seconds as well (this might no be needed).

struct appdata_t {
    float4 vertex : POSITION;
    float2 texcoord0 : TEXCOORD0;
    float2 texcoord1 : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct v2f {
    float4 vertex : SV_POSITION;
    float2 texcoord0 : TEXCOORD0;
    float2 texcoord1 : TEXCOORD1;
    UNITY_VERTEX_OUTPUT_STEREO
};

v2f vert (appdata_t v)
{
    v2f o;
    UNITY_SETUP_INSTANCE_ID(v);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
    float3 rotated = RotateAroundYInDegrees(v.vertex, _Rotation);
    o.vertex = UnityObjectToClipPos(rotated);
    o.texcoord0 = v.texcoord0;
    o.texcoord1 = v.texcoord1;
    return o;
}

For smoother blending we define this ParametricBlend function which gives us a nice curve.

float ParametricBlend(float t)
{
    float sqt = t*t;
    return sqt / (2.0f * (sqt - t) + 1.0f);
}

Finally, the fragment shader does the blending calculations.

half4 skybox_frag (v2f i, sampler2D smp0, sampler2D smp1, half4 smpDecode0, half4 smpDecode1)
{
    half4 tex0 = tex2D (smp0, i.texcoord0);
    half4 tex1 = tex2D (smp1, i.texcoord1);
    half3 c0 = DecodeHDR (tex0, smpDecode0);
    half3 c1 = DecodeHDR (tex1, smpDecode1);
    c0 = c0 * _Tint.rgb * unity_ColorSpaceDouble.rgb * (1.0 - ParametricBlend(_Blend));
    c0 += c1 * _Tint.rgb * unity_ColorSpaceDouble.rgb * ParametricBlend(_Blend);
    c0 *= _Exposure;
    return half4(c0, 1);
}

The full shader looks like this:

Shader "Lucidum/Pareidolia/Skybox/Skybox" {
    Properties {
        _Tint ("Tint Color", Color) = (.5, .5, .5, .5)
        [Gamma] _Exposure ("Exposure", Range(0, 8)) = 1.0
        _Rotation ("Rotation", Range(0, 360)) = 0
        _Blend ("Blend", Range(0.0, 1.0)) = 0
        [NoScaleOffset] _FrontTex0 ("Front [+Z]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _FrontTex1 ("Front [+Z]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _BackTex0 ("Back [-Z]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _BackTex1 ("Back [-Z]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _LeftTex0 ("Left [+X]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _LeftTex1 ("Left [+X]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _RightTex0 ("Right [-X]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _RightTex1 ("Right [-X]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _UpTex0 ("Up [+Y]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _UpTex1 ("Up [+Y]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _DownTex0 ("Down [-Y]   (HDR)", 2D) = "grey" {}
        [NoScaleOffset] _DownTex1 ("Down [-Y]   (HDR)", 2D) = "grey" {}
    }

    SubShader {
        Tags { "Queue"="Background" "RenderType"="Background" "PreviewType"="Skybox" }
        Cull Off ZWrite Off

        CGINCLUDE
        #include "UnityCG.cginc"

        half4 _Tint;
        half _Exposure;
        float _Rotation;
        float _Blend;

        float3 RotateAroundYInDegrees (float3 vertex, float degrees)
        {
            float alpha = degrees * UNITY_PI / 180.0;
            float sina, cosa;
            sincos(alpha, sina, cosa);
            float2x2 m = float2x2(cosa, -sina, sina, cosa);
            return float3(mul(m, vertex.xz), vertex.y).xzy;
        }

        struct appdata_t {
            float4 vertex : POSITION;
            float2 texcoord0 : TEXCOORD0;
            float2 texcoord1 : TEXCOORD1;
            UNITY_VERTEX_INPUT_INSTANCE_ID
        };

        struct v2f {
            float4 vertex : SV_POSITION;
            float2 texcoord0 : TEXCOORD0;
            float2 texcoord1 : TEXCOORD1;
            UNITY_VERTEX_OUTPUT_STEREO
        };

        v2f vert (appdata_t v)
        {
            v2f o;
            UNITY_SETUP_INSTANCE_ID(v);
            UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
            float3 rotated = RotateAroundYInDegrees(v.vertex, _Rotation);
            o.vertex = UnityObjectToClipPos(rotated);
            o.texcoord0 = v.texcoord0;
            o.texcoord1 = v.texcoord1;
            return o;
        }

        float ParametricBlend(float t)
        {
            float sqt = t*t;
            return sqt / (2.0f * (sqt - t) + 1.0f);
        }

        half4 skybox_frag (v2f i, sampler2D smp0, sampler2D smp1, half4 smpDecode0, half4 smpDecode1)
        {
            half4 tex0 = tex2D (smp0, i.texcoord0);
            half4 tex1 = tex2D (smp1, i.texcoord1);
            half3 c0 = DecodeHDR (tex0, smpDecode0);
            half3 c1 = DecodeHDR (tex1, smpDecode1);
            c0 = c0 * _Tint.rgb * unity_ColorSpaceDouble.rgb * (1.0 - ParametricBlend(_Blend));
            c0 += c1 * _Tint.rgb * unity_ColorSpaceDouble.rgb * ParametricBlend(_Blend);
            c0 *= _Exposure;
            return half4(c0, 1);
        }
        ENDCG

        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            sampler2D _FrontTex0;
            sampler2D _FrontTex1;
            half4 _FrontTex0_HDR;
            half4 _FrontTex1_HDR;
            half4 frag (v2f i) : SV_Target {
                return skybox_frag(i, _FrontTex0, _FrontTex1, _FrontTex0_HDR, _FrontTex1_HDR);
            }
            ENDCG
        }
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            sampler2D _BackTex0;
            sampler2D _BackTex1;
            half4 _BackTex0_HDR;
            half4 _BackTex1_HDR;
            half4 frag (v2f i) : SV_Target {
                return skybox_frag(i, _BackTex0, _BackTex1, _BackTex0_HDR, _BackTex1_HDR);
            }
            ENDCG
        }
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            sampler2D _LeftTex0;
            sampler2D _LeftTex1;
            half4 _LeftTex0_HDR;
            half4 _LeftTex1_HDR;
            half4 frag (v2f i) : SV_Target {
                return skybox_frag(i, _LeftTex0, _LeftTex1, _LeftTex0_HDR, _LeftTex1_HDR);
            }
            ENDCG
        }
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            sampler2D _RightTex0;
            sampler2D _RightTex1;
            half4 _RightTex0_HDR;
            half4 _RightTex1_HDR;
            half4 frag (v2f i) : SV_Target {
                return skybox_frag(i, _RightTex0, _RightTex1, _RightTex0_HDR, _RightTex1_HDR);
            }
            ENDCG
        }
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            sampler2D _UpTex0;
            sampler2D _UpTex1;
            half4 _UpTex0_HDR;
            half4 _UpTex1_HDR;
            half4 frag (v2f i) : SV_Target {
                return skybox_frag(i, _UpTex0, _UpTex1, _UpTex0_HDR, _UpTex1_HDR);
            }
            ENDCG
        }
        Pass{
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            sampler2D _DownTex0;
            sampler2D _DownTex1;
            half4 _DownTex0_HDR;
            half4 _DownTex1_HDR;
            half4 frag (v2f i) : SV_Target {
                return skybox_frag(i, _DownTex0, _DownTex1, _DownTex0_HDR, _DownTex1_HDR);
            }
            ENDCG
        }
    }
}

The main script

The main logic is in the DayNightCycle script. To control the light it's as simple as adding this script to a directional light source that will serve as out main light. Let's break down the script to see what it does:

References our main light. It's private because it's initialized in the Start method.

private Light mainLight;

Time of day (amount of seconds after midnight) to use in our calculations. This will give us a nice slider in the inspector to control the cycle.

[Range(0.0f, 86400.0f)]
public int Seconds = 34000;

Nothing special in the start method, sets the main light or throws an exception if it's not found.

private void Start()
{
    this.mainLight = this.GetComponent<Light>();
    if (this.mainLight == null)
    {
        throw new MissingComponentException("Missing Light");
    }
}

In the update method we calculate and set the light and skybox variables:

  • The position of the light starts at -300 y and is rotated around the x axis by the time of day divided by a full day times 360 degrees, so that a full day equals one full rotation.
  • The rotation of the light is set to look towards the origin.
  • We also calculate the light intensity and color depending on the time of day.
  • Finally we set blend and rotation variables in the shader.
private void Update()
{
    Vector3 position =
        Quaternion.Euler(((float)this.Seconds/86400.0f)*360.0f, 0, 0) *
        new Vector3(0.0f, -300.0f, 0.0f);
    this.transform.position = position;
    this.transform.rotation = Quaternion.LookRotation(-position);
    this.mainLight.intensity = 1.25f - Math.Abs((float)this.Seconds/43200.0f-1.0f);
    this.mainLight.color = new Color(
        1.0f,
        Math.Min(this.mainLight.intensity + 0.05f, 1.0f),
        Math.Min(this.mainLight.intensity, 1.0f)
    );
    RenderSettings.skybox.SetFloat("_Blend", Math.Abs((float)this.Seconds/43200.0f-1.0f));
    RenderSettings.skybox.SetFloat("_Rotation", ((float)this.Seconds/86400.0f)*360.0f);
}

The full script looks like this:

// <copyright file="DayNightCycle.cs" company="Studio Lucidum AS">
// Copyright (c) Studio Lucidum AS. All rights reserved.
// </copyright>

namespace Lucidum.Pareidolia.GameScripts.World.Light
{
    using System;
    using UnityEngine;
    using UnityEditor;

    [ExecuteInEditMode]
    [DisallowMultipleComponent]
    public class DayNightCycle : MonoBehaviour
    {
        private Light mainLight;

        [Range(0.0f, 86400.0f)]
        public int Seconds = 34000;

        private void Start()
        {
            this.mainLight = this.GetComponent<Light>();
            if (this.mainLight == null)
            {
                throw new MissingComponentException("Missing Light");
            }
        }

        private void Update()
        {
            Vector3 position =
                Quaternion.Euler(((float)this.Seconds/86400.0f)*360.0f, 0, 0) *
                new Vector3(0.0f, -300.0f, 0.0f);
            this.transform.position = position;
            this.transform.rotation = Quaternion.LookRotation(-position);
            this.mainLight.intensity = 1.25f - Math.Abs((float)this.Seconds/43200.0f-1.0f);
            this.mainLight.color = new Color(
                1.0f,
                Math.Min(this.mainLight.intensity + 0.05f, 1.0f),
                Math.Min(this.mainLight.intensity, 1.0f)
            );
            RenderSettings.skybox.SetFloat("_Blend", Math.Abs((float)this.Seconds/43200.0f-1.0f));
            RenderSettings.skybox.SetFloat("_Rotation", ((float)this.Seconds/86400.0f)*360.0f);
        }
    }
}

This lighting system is currently only working with manual setup, so the next step would be to automate the cycle where it continously increases the time of day or sets the time of day based on checkpoints the player triggers.

Anyway, enjoy your new lighting system!