Files
Emberwild/Assets/GAME/Script/Environment/TimeOfDayManager.cs
Mathew 78bfdf2828 Merge remote-tracking branch 'origin/feat/inport-props' into feat/craft-discovery
# Conflicts:
#	Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta
#	Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
2026-07-25 19:42:26 +02:00

421 lines
18 KiB
C#

using UnityEngine;
using UnityEngine.Rendering;
namespace Ashwild.Environment
{
/// <summary>
/// Drives the day/night cycle for the self-contained "GAME/StylizedSky" skybox. Rotates
/// the sun Directional Light and evaluates one palette per moment of day (sky gradient,
/// sun/moon color, cloud colors, star opacity) straight onto the sky MATERIAL's
/// properties — the shader stays fully self-contained, so with no manager the material
/// still renders its own authored look and this component only overrides the
/// time-dependent values on top. Runs in edit mode (ExecuteAlways) so the sky updates
/// live while scrubbing the time slider.
///
/// Purely visual and LOCAL for now: like SeasonManager it is not yet networked. The
/// single seam to sync in co-op is the time value itself — feed it through
/// <see cref="SetTimeOfDay"/> from a server world-clock (SyncVar) instead of the local
/// auto-advance, and every client's sky and lighting follows (see CLAUDE.md §3).
/// </summary>
[ExecuteAlways]
[DisallowMultipleComponent]
[AddComponentMenu("GAME/Environment/Time Of Day Manager")]
public class TimeOfDayManager : MonoBehaviour
{
#region Singleton
public static TimeOfDayManager Instance { get; private set; }
#endregion
#region Serialized Fields
[Header("References")]
[Tooltip("Directional light rotated to act as the sun. Optional but recommended.")]
[SerializeField] private Light sunLight;
[Tooltip("Second directional light acting as the moon: cool, dim, lit only at night, aimed opposite the sun. Optional.")]
[SerializeField] private Light moonLight;
[Tooltip("Material using GAME/StylizedSky. Assigned to RenderSettings.skybox on enable.")]
[SerializeField] private Material skyMaterial;
[Header("Time")]
[Tooltip("0 = midnight, 0.25 = sunrise, 0.5 = noon, 0.75 = sunset.")]
[Range(0f, 1f)] [SerializeField] private float timeOfDay = 0.5f;
[SerializeField] private bool autoAdvance = true;
[Tooltip("Real seconds for one full day/night cycle. Only advances in Play.")]
[SerializeField] private float dayLengthSeconds = 300f;
[Tooltip("Compass offset of the sun's arc, in degrees.")]
[Range(0f, 360f)] [SerializeField] private float sunYaw = 20f;
[Header("Sun Light")]
[SerializeField] private Gradient sunColorOverDay = new Gradient();
[SerializeField] private AnimationCurve sunIntensityOverDay = AnimationCurve.Constant(0f, 1f, 1f);
[Tooltip("Peak directional-light intensity reached around noon.")]
[SerializeField] private float maxSunIntensity = 1.3f;
[Header("Sky Gradient Over Day")]
[SerializeField] private Gradient zenithColorOverDay = new Gradient();
[SerializeField] private Gradient horizonColorOverDay = new Gradient();
[SerializeField] private Gradient groundColorOverDay = new Gradient();
[Header("Clouds Over Day")]
[SerializeField] private Gradient cloudColorOverDay = new Gradient();
[SerializeField] private Gradient cloudShadowColorOverDay = new Gradient();
[Header("Moon and Stars")]
[SerializeField] private Color moonColor = new Color(0.85f, 0.88f, 0.96f);
[Tooltip("Cool tint of the moonlight cast on the scene at night.")]
[SerializeField] private Color moonLightColor = new Color(0.55f, 0.62f, 0.85f);
[Tooltip("Peak moonlight intensity reached in the dead of night.")]
[SerializeField] private float maxMoonIntensity = 0.3f;
[Tooltip("Sun height at which stars/moon start fading in (upper) and are fully visible (lower).")]
[SerializeField] private float starFadeInElevation = 0.08f;
[SerializeField] private float starFullElevation = -0.15f;
[Header("Fog")]
[Tooltip("Tint the built-in RenderSettings fog with the horizon color. Leave off when an external fog (e.g. Better Fog) is driven from CurrentHorizonColor instead.")]
[SerializeField] private bool driveFogColor = true;
#endregion
#region Shader Property IDs
private static readonly int ZenithColorId = Shader.PropertyToID("_ZenithColor");
private static readonly int HorizonColorId = Shader.PropertyToID("_HorizonColor");
private static readonly int GroundColorId = Shader.PropertyToID("_GroundColor");
private static readonly int SunDirectionId = Shader.PropertyToID("_SunDirection");
private static readonly int SunColorId = Shader.PropertyToID("_SunColor");
private static readonly int MoonDirectionId = Shader.PropertyToID("_MoonDirection");
private static readonly int MoonColorId = Shader.PropertyToID("_MoonColor");
private static readonly int CloudColorId = Shader.PropertyToID("_CloudColor");
private static readonly int CloudShadowColorId = Shader.PropertyToID("_CloudShadowColor");
private static readonly int StarOpacityId = Shader.PropertyToID("_StarOpacity");
#endregion
#region State
private float lastAmbientRefresh;
#endregion
#region Unity Lifecycle
/// <summary>
/// Registers the instance, makes the sky material active and pushes an initial state
/// so the sky is correct the moment the component becomes active (including in edit).
/// </summary>
private void OnEnable()
{
Instance = this;
if (skyMaterial != null)
RenderSettings.skybox = skyMaterial;
RenderSettings.ambientMode = AmbientMode.Skybox;
RenderSettings.defaultReflectionMode = DefaultReflectionMode.Skybox;
Apply();
}
/// <summary>
/// Clears the shared instance so a disabled manager never keeps answering as current.
/// </summary>
private void OnDisable()
{
if (Instance == this)
Instance = null;
}
/// <summary>
/// Re-clamps tuning and refreshes the sky whenever a value is edited in the inspector.
/// </summary>
private void OnValidate()
{
dayLengthSeconds = Mathf.Max(1f, dayLengthSeconds);
Apply();
}
/// <summary>
/// Advances time (Play only) and re-applies the palette every frame.
/// </summary>
private void Update()
{
if (Application.isPlaying && autoAdvance)
timeOfDay = Mathf.Repeat(timeOfDay + Time.deltaTime / dayLengthSeconds, 1f);
Apply();
}
#endregion
#region Apply
/// <summary>
/// Resolves the current time of day into sun orientation and the full sky palette,
/// writes it onto the sky material, and mirrors the sun color/intensity onto the
/// directional light so scene lighting matches the sky. Single source of every
/// time-dependent value. No-op without a material to drive.
/// </summary>
private void Apply()
{
Vector3 sunDir = OrientSun();
float nightFactor = Mathf.SmoothStep(0f, 1f,
Mathf.InverseLerp(starFadeInElevation, starFullElevation, sunDir.y));
Color sunColor = sunColorOverDay.Evaluate(timeOfDay);
DriveSunLight(sunColor);
DriveMoonLight(sunDir, nightFactor);
PromoteMainLight(nightFactor);
Color horizon = horizonColorOverDay.Evaluate(timeOfDay);
CurrentHorizonColor = horizon;
CurrentSunColor = sunColor;
CurrentNightFactor = nightFactor;
if (driveFogColor)
RenderSettings.fogColor = ToRendered(horizon);
if (skyMaterial == null)
{
RepaintEditor();
return;
}
skyMaterial.SetColor(ZenithColorId, ToRendered(zenithColorOverDay.Evaluate(timeOfDay)));
skyMaterial.SetColor(HorizonColorId, ToRendered(horizon));
skyMaterial.SetColor(GroundColorId, ToRendered(groundColorOverDay.Evaluate(timeOfDay)));
skyMaterial.SetVector(SunDirectionId, sunDir);
skyMaterial.SetColor(SunColorId, ToRendered(sunColorOverDay.Evaluate(timeOfDay)));
skyMaterial.SetVector(MoonDirectionId, -sunDir);
skyMaterial.SetColor(MoonColorId, ToRendered(moonColor));
skyMaterial.SetColor(CloudColorId, ToRendered(cloudColorOverDay.Evaluate(timeOfDay)));
skyMaterial.SetColor(CloudShadowColorId, ToRendered(cloudShadowColorOverDay.Evaluate(timeOfDay)));
skyMaterial.SetFloat(StarOpacityId, nightFactor);
RefreshAmbient();
RepaintEditor();
}
/// <summary>
/// Recomputes ambient light and the skybox reflection from the current (procedural)
/// sky so scene lighting tracks the day — Unity does NOT do this automatically for a
/// material that changes every frame. Throttled to a few times a second because
/// rebaking the environment probe every frame would stutter the editor.
/// </summary>
private void RefreshAmbient()
{
float now = Time.realtimeSinceStartup;
if (now - lastAmbientRefresh < 0.15f) return;
lastAmbientRefresh = now;
DynamicGI.UpdateEnvironment();
}
/// <summary>
/// Rotates the sun light so noon sits overhead and sunrise/sunset sit on the horizon,
/// and returns the world-space direction pointing toward the sun (what the shader
/// wants). Works from the current time even when no light is assigned.
/// </summary>
private Vector3 OrientSun()
{
float pitch = timeOfDay * 360f - 90f;
Quaternion rotation = Quaternion.Euler(pitch, sunYaw, 0f);
if (sunLight != null)
sunLight.transform.rotation = rotation;
return -(rotation * Vector3.forward);
}
/// <summary>
/// Tints the directional light and fades its intensity to zero at night so the world
/// darkens together with the sky. No-op when no light is wired up.
/// </summary>
private void DriveSunLight(Color sunColor)
{
if (sunLight == null) return;
sunLight.color = sunColor;
sunLight.intensity = sunIntensityOverDay.Evaluate(timeOfDay) * maxSunIntensity;
}
/// <summary>
/// Aims the moon light opposite the sun (its light travels toward the sun direction, i.e.
/// comes from the moon), tints it cool, and fades its intensity in with nightFactor so it
/// only lights the scene once the sun is down. No-op when no moon light is wired up.
/// </summary>
private void DriveMoonLight(Vector3 sunDir, float nightFactor)
{
if (moonLight == null) return;
moonLight.transform.rotation = Quaternion.LookRotation(sunDir);
moonLight.color = moonLightColor;
moonLight.intensity = nightFactor * maxMoonIntensity;
}
/// <summary>
/// Points URP's main (shadow-casting) light at whichever body is currently up — sun by
/// day, moon by night. Without this, a pinned Sun Source keeps the dark night sun as the
/// main light and shadows vanish once it sets; swapping to the moon keeps shadows going.
/// </summary>
private void PromoteMainLight(float nightFactor)
{
Light mainLight = nightFactor > 0.5f && moonLight != null ? moonLight : sunLight;
if (mainLight != null)
RenderSettings.sun = mainLight;
}
/// <summary>
/// Converts an authored sRGB color to the value the shader must receive. Colors
/// pushed via Material.SetColor are NOT auto-gamma-corrected (unlike the inspector
/// color field), so in a linear project they must be pre-converted or the whole sky
/// washes out to white.
/// </summary>
private static Color ToRendered(Color c)
{
return QualitySettings.activeColorSpace == ColorSpace.Linear ? c.linear : c;
}
/// <summary>
/// Forces the Scene view to redraw while scrubbing values outside Play, matching
/// SeasonManager, so the sky updates live without entering Play mode.
/// </summary>
private void RepaintEditor()
{
#if UNITY_EDITOR
if (!Application.isPlaying)
UnityEditor.SceneView.RepaintAll();
#endif
}
#endregion
#region Public API
/// <summary>
/// Current normalized time of day (0 = midnight, 0.5 = noon).
/// </summary>
public float TimeOfDay => timeOfDay;
/// <summary>
/// The horizon color resolved for the current time (authored/gamma space). External
/// atmosphere systems (e.g. the Better Fog driver) read this to tint fog with the sky.
/// </summary>
public Color CurrentHorizonColor { get; private set; }
/// <summary>
/// The sun color resolved for the current time (authored/gamma space) — for fog sun
/// scattering or any effect that should match the sun tint.
/// </summary>
public Color CurrentSunColor { get; private set; }
/// <summary>
/// 0 by day, 1 in the dead of night (same curve that fades in the stars and the moon).
/// </summary>
public float CurrentNightFactor { get; private set; }
/// <summary>
/// Sets the time of day directly (wrapped into [0,1)) and refreshes the sky. This is
/// the seam a networked world-clock drives from — call it on every client with the
/// server time instead of relying on the local auto-advance.
/// </summary>
public void SetTimeOfDay(float normalized)
{
timeOfDay = Mathf.Repeat(normalized, 1f);
Apply();
}
#endregion
#region Defaults
/// <summary>
/// Seeds every gradient/curve with a bright stylized daytime palette (blue noon,
/// warm dawn/dusk, deep-blue night) so a freshly added manager looks right without
/// hand-authoring. Invoked by the editor when the component is added or reset.
/// </summary>
private void Reset()
{
timeOfDay = 0.5f;
sunYaw = 20f;
dayLengthSeconds = 300f;
SeedStylizedPreset();
}
/// <summary>
/// Re-applies the tuned palette WITHOUT clearing the wired references (Sun Light,
/// Sky Material) or the current time — unlike Reset, which nulls everything. Exposed
/// on the context menu so the palette can be re-seeded during iteration.
/// </summary>
[ContextMenu("Apply Stylized Sky Preset")]
private void SeedStylizedPreset()
{
zenithColorOverDay = BuildGradient(
(0.00f, new Color(0.03f, 0.05f, 0.12f)),
(0.23f, new Color(0.22f, 0.30f, 0.52f)),
(0.32f, new Color(0.20f, 0.42f, 0.80f)),
(0.50f, new Color(0.20f, 0.45f, 0.85f)),
(0.68f, new Color(0.20f, 0.40f, 0.78f)),
(0.78f, new Color(0.24f, 0.28f, 0.50f)),
(1.00f, new Color(0.03f, 0.05f, 0.12f)));
horizonColorOverDay = BuildGradient(
(0.00f, new Color(0.05f, 0.07f, 0.14f)),
(0.23f, new Color(0.95f, 0.58f, 0.40f)),
(0.32f, new Color(0.75f, 0.86f, 0.95f)),
(0.50f, new Color(0.72f, 0.85f, 0.95f)),
(0.68f, new Color(0.78f, 0.84f, 0.92f)),
(0.78f, new Color(0.97f, 0.52f, 0.35f)),
(1.00f, new Color(0.05f, 0.07f, 0.14f)));
groundColorOverDay = BuildGradient(
(0.00f, new Color(0.03f, 0.04f, 0.08f)),
(0.25f, new Color(0.35f, 0.33f, 0.32f)),
(0.50f, new Color(0.40f, 0.44f, 0.48f)),
(0.75f, new Color(0.36f, 0.31f, 0.30f)),
(1.00f, new Color(0.03f, 0.04f, 0.08f)));
sunColorOverDay = BuildGradient(
(0.00f, new Color(0.35f, 0.40f, 0.55f)),
(0.23f, new Color(1.00f, 0.55f, 0.30f)),
(0.50f, new Color(1.00f, 0.96f, 0.88f)),
(0.77f, new Color(1.00f, 0.52f, 0.28f)),
(1.00f, new Color(0.35f, 0.40f, 0.55f)));
cloudColorOverDay = BuildGradient(
(0.00f, new Color(0.14f, 0.17f, 0.26f)),
(0.25f, new Color(1.00f, 0.74f, 0.58f)),
(0.50f, new Color(1.00f, 1.00f, 1.00f)),
(0.75f, new Color(1.00f, 0.72f, 0.55f)),
(1.00f, new Color(0.14f, 0.17f, 0.26f)));
cloudShadowColorOverDay = BuildGradient(
(0.00f, new Color(0.06f, 0.08f, 0.16f)),
(0.25f, new Color(0.60f, 0.45f, 0.52f)),
(0.50f, new Color(0.58f, 0.65f, 0.82f)),
(0.75f, new Color(0.58f, 0.43f, 0.50f)),
(1.00f, new Color(0.06f, 0.08f, 0.16f)));
sunIntensityOverDay = new AnimationCurve(
new Keyframe(0.00f, 0f), new Keyframe(0.23f, 0.05f), new Keyframe(0.30f, 0.8f),
new Keyframe(0.50f, 1f), new Keyframe(0.70f, 0.8f), new Keyframe(0.78f, 0.05f),
new Keyframe(1.00f, 0f));
}
/// <summary>
/// Builds a Gradient from (time, color) tuples with full alpha, keeping color
/// authoring in one compact place. Alpha is unused by the sky (every color opaque).
/// </summary>
private static Gradient BuildGradient(params (float time, Color color)[] stops)
{
var colorKeys = new GradientColorKey[stops.Length];
for (int i = 0; i < stops.Length; i++)
colorKeys[i] = new GradientColorKey(stops[i].color, stops[i].time);
var gradient = new Gradient();
gradient.SetKeys(colorKeys, new[]
{
new GradientAlphaKey(1f, 0f),
new GradientAlphaKey(1f, 1f)
});
return gradient;
}
#endregion
}
}