유니티 Day Night System #밤 낮을 바꿔보자

Day Night System

 


시간을 세팅하자

아침을 몇시로 세팅 할 지, 저녁은 몇 시로 할 것인지, 흐르는 시간의 배율 등을 세팅할 수 있게 도와주는 SO를 만들어보겠습니다.

using UnityEngine;

[CreateAssetMenu(fileName = "TimeSettings", menuName = "TimeSettings")]
public class TimeSettings : ScriptableObject {
    public float timeMultiplier = 2000;
    public float startHour = 12;
    public float sunriseHour = 6;
    public float sunsetHour = 18;
}

 


시간의 흐름과 낮/밤의 변화를 관리하자

using System;
using UnityEngine;

public class TimeService {
    readonly TimeSettings settings;
    DateTime currentTime;
    readonly TimeSpan sunriseTime;
    readonly TimeSpan sunsetTime;

    public DateTime CurrentTime => currentTime;

    public event Action OnSunrise = delegate { };
    public event Action OnSunset = delegate { };
    public event Action OnHourChange = delegate { };

    readonly Observable<bool> isDayTime;
    readonly Observable<int> currentHour;

    public TimeService(TimeSettings settings) {
        this.settings = settings;
        currentTime = DateTime.Now.Date + TimeSpan.FromHours(settings.startHour);
        sunriseTime = TimeSpan.FromHours(settings.sunriseHour);
        sunsetTime = TimeSpan.FromHours(settings.sunsetHour);
        
        isDayTime = new Observable<bool>(IsDayTime());
        currentHour = new Observable<int>(currentTime.Hour);
        
        isDayTime.ValueChanged += day => (day ? OnSunrise : OnSunset)?.Invoke();
        currentHour.ValueChanged += _ => OnHourChange?.Invoke();
    }

    public void UpdateTime(float deltaTime) {
        currentTime = currentTime.AddSeconds(deltaTime * settings.timeMultiplier);
        isDayTime.Value = IsDayTime();
        currentHour.Value = currentTime.Hour;
    }
    
    public float CalculateSunAngle() {
        bool isDay = IsDayTime();
        float startDegree = isDay ? 0 : 180;
        TimeSpan start = isDay ? sunriseTime : sunsetTime;
        TimeSpan end = isDay ? sunsetTime : sunriseTime;
        
        TimeSpan totalTime = CalculateDifference(start, end);
        TimeSpan elapsedTime = CalculateDifference(start, currentTime.TimeOfDay);

        double percentage = elapsedTime.TotalMinutes / totalTime.TotalMinutes;
        return Mathf.Lerp(startDegree, startDegree + 180, (float) percentage);
    }

    //현재 시간이 낮인지 여부를 판단
    bool IsDayTime() => currentTime.TimeOfDay > sunriseTime && currentTime.TimeOfDay < sunsetTime;
    
    //두 시간 간의 차이를 계산하는 메서드
    TimeSpan CalculateDifference(TimeSpan from, TimeSpan to) {
        TimeSpan difference = to - from;
        return difference.TotalHours < 0 ? difference + TimeSpan.FromHours(24) : difference;
    }
}

 

TimeService 메서드는 시간의 흐름과 낮/밤의 변화를 관리하고, 이에 따른 이벤트를 발생시키는 역할을 합니다.

 


옵저버 패턴

using System;
using System.Collections.Generic;

[Serializable]
public class Observable<T> {
    private T value;
    public event Action<T> ValueChanged;

    public T Value {
        get => value;
        set => Set(value);
    }

    public static implicit operator T(Observable<T> observable) => observable.value;

    public Observable(T value, Action<T> onValueChanged = null) {
        this.value = value;

        if (onValueChanged != null)
            ValueChanged += onValueChanged;
    }

    public void Set(T value) {
        if (EqualityComparer<T>.Default.Equals(this.value, value))
            return;
        this.value = value;
        Invoke();
    }
    
    public void Invoke() {
        ValueChanged?.Invoke(value);
    }

    public void AddListener(Action<T> handler) {
        ValueChanged += handler;
    }

    public void RemoveListener(Action<T> handler) {
        ValueChanged -= handler;
    }

    public void Dispose() {
        ValueChanged = null;
        value = default;
    }
}

 

옵저버 패턴에 대해서는 나중에 다뤄보겠습니다. 간단하게 설명하자면 특정 객체를 계속 바라보면서 그 객체가 변하는지 안변하는지를 관찰하는 패턴이라고 생각하면 됩니다. 예를 들어, 객체가 아침이 되었는지 밤이 되었는지 상태의 변화를 관찰할 때 사용하면 좋은 패턴입니다. 

 


SkyBox Shader

Shader "Skybox/NightDay"
{
    Properties
    {
        _Texture1("Texture1", 2D) = "white" {}
        _Texture2("Texture2", 2D) = "white" {}
        _Blend("Blend", Range(0, 1)) = 0.5
    }
    SubShader
    {
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float3 texcoord : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _Texture1;
            sampler2D _Texture2;
            float _Blend;

            v2f vert(appdata v)
            {
                v2f o;
                o.texcoord = v.vertex.xyz;
                o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }

            float2 ToRadialCoords(float3 coords)
            {
                float3 normalizedCoords = normalize(coords);
                float latitude = acos(normalizedCoords.y);
                float longitude = atan2(normalizedCoords.z, normalizedCoords.x);
                const float2 sphereCoords = float2(longitude, latitude) * float2(0.5 / UNITY_PI, 1.0 / UNITY_PI);
                return float2(0.5, 1.0) - sphereCoords;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float2 tc = ToRadialCoords(i.texcoord);
                fixed4 tex1 = tex2D(_Texture1, tc);
                fixed4 tex2 = tex2D(_Texture2, tc);
                return lerp(tex1, tex2, _Blend);
            }
            ENDCG
        }
    }
}

 

매우 간단한 코드입니다. 두 개의 스카이박스 텍스쳐를 블렌더시켜주는 쉐이더입니다.

 

blender shader

 

쉐이더 코드를 작성한 후 메테리얼을 만들어주고 밤/낮 텍스쳐를 할당해주면 됩니다.

 


Time Manager

using System;
using UnityEngine;
using TMPro;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

public class TimeManager : MonoBehaviour {
    [SerializeField] TextMeshProUGUI timeText;
    
    [SerializeField] Light sun;
    [SerializeField] Light moon;
    [SerializeField] AnimationCurve lightIntensityCurve;
    [SerializeField] float maxSunIntensity = 1;
    [SerializeField] float maxMoonIntensity = 0.5f;
    
    [SerializeField] Color dayAmbientLight;
    [SerializeField] Color nightAmbientLight;
    [SerializeField] Volume volume;
    [SerializeField] Material skyboxMaterial;
    
    [SerializeField] RectTransform dial;
    float initialDialRotation;
    
    ColorAdjustments colorAdjustments;
    
    [SerializeField] TimeSettings timeSettings;
    
    public event Action OnSunrise {
        add => service.OnSunrise += value;
        remove => service.OnSunrise -= value;
    }
    
    public event Action OnSunset {
        add => service.OnSunset += value;
        remove => service.OnSunset -= value;
    }
    
    public event Action OnHourChange {
        add => service.OnHourChange += value;
        remove => service.OnHourChange -= value;
    }    

    TimeService service;

    void Start() {
        service = new TimeService(timeSettings);
        volume.profile.TryGet(out colorAdjustments);
        OnSunrise += () => Debug.Log("Sunrise");
        OnSunset += () => Debug.Log("Sunset");
        OnHourChange += () => Debug.Log("Hour change");
        
        initialDialRotation = dial.rotation.eulerAngles.z;
    }

    void Update() {
        UpdateTimeOfDay();
        RotateSun();
        UpdateLightSettings();
        UpdateSkyBlend();
    }

    void UpdateSkyBlend() {
        float dotProduct = Vector3.Dot(sun.transform.forward, Vector3.up);
        float blend = Mathf.Lerp(0, 1, lightIntensityCurve.Evaluate(dotProduct));
        skyboxMaterial.SetFloat("_Blend", blend);
    }
    
    void UpdateLightSettings() {
        float dotProduct = Vector3.Dot(sun.transform.forward, Vector3.down);
        float lightIntensity = lightIntensityCurve.Evaluate(dotProduct);
        
        sun.intensity = Mathf.Lerp(0, maxSunIntensity, lightIntensity);
        moon.intensity = Mathf.Lerp(maxMoonIntensity, 0, lightIntensity);
        
        if (colorAdjustments == null) return;
        colorAdjustments.colorFilter.value = Color.Lerp(nightAmbientLight, dayAmbientLight, lightIntensity);
    }

    void RotateSun() {
        float rotation = service.CalculateSunAngle();
        sun.transform.rotation = Quaternion.AngleAxis(rotation, Vector3.right);
        dial.rotation = Quaternion.Euler(0, 0, rotation + initialDialRotation);
    }

    void UpdateTimeOfDay() {
        service.UpdateTime(Time.deltaTime);
        if (timeText != null) {
            timeText.text = service.CurrentTime.ToString("hh:mm");
        }
    }
}

 

위에서 작성한 모든 코드들을 사용 및 관리하는 스크립트입니다.

TimeManager

 

TimeManager 스크립트를 붙여주신 다음, Sun, Moon 등 해당하는 프로퍼티들을 할당해줍니다.

Light / Volume

 

Sun과 Moon의 경우 각도를 90,0,0으로 초기화해줍니다. 

Volume의 경우 Color Adjusments를 오버라이드 해준 다음 Color Filter를 활성화해줍니다.

 


결과

Wow!!

 

게임이 엄청 사실적으로 변했네요😁

VR 게임같은데서 사용하면 더 몰입이 잘되겠죠?! 바로 적용해보러 가겠습니다 뿅~