Pure utility for day/night grading. Computes sun rotation and four color grades (sun, sky ambient, sky tint, env tint) from a total game-hour, weather and a DayNightConfig, provides clock-rate scaling and an Apply method that writes to engine lights/sky/env.
using System;
using Sandbox;
namespace FieldGuide.DayNight;
/// <summary>
/// The pure grading math: a total game-hour + weather + config in, a sun rotation and four colour grades out.
/// No engine state, no randomness, so a host and every client compute the SAME look from the same clock, and
/// the editor / a headless test can render or check the exact cycle a play session shows.
///
/// ANCHOR-EXACT BY CONSTRUCTION: at <see cref="DayNightConfig.AnchorHours"/> (Clear) every output equals the
/// reference grade in the config, so pinning the anchor reproduces the authored look byte-for-byte. The
/// daytime arc is derived from <see cref="DayNightConfig.SunDirection"/> and lerps FROM the reference colours,
/// and a twilight band smoothstep-blends dusk/dawn to the night grade so there is no hard flip at the horizon.
///
/// This math NEVER touches exposure, shadows, or fog. Apply it and your night is dark because the sun and sky
/// colours are dark, not because tone-mapping moved.
/// </summary>
public static class SkyGrade
{
/// <summary>Smoothstep 0→1 with clamp, the twilight blend easing (deterministic; no engine state).</summary>
public static float Smoothstep( float x )
{
x = Math.Clamp( x, 0f, 1f );
return x * x * (3f - 2f * x);
}
/// <summary>Compute the sun rotation + all four colour grades for a TOTAL game-hour + weather. Anchor-exact:
/// at <see cref="DayNightConfig.AnchorHours"/> (Clear) every value equals the config reference grade. Never
/// computes exposure, that stays whatever your camera set it to.</summary>
public static void ComputeGrade( float total, WeatherKind weather, in DayNightConfig cfg,
out Rotation sunRot, out Color sunColor, out Color skyColor, out Color skyTint, out Color envTint )
{
var anchor = Rotation.LookAt( cfg.SunDirection.Normal ).Angles(); // reference sun pitch/yaw
int day = (int)MathF.Floor( total / 24f );
float t = total - day * 24f; // hour-of-day 0..24
float sunrise = cfg.SunriseHour, sunset = cfg.SunsetHour;
bool isDay = t >= sunrise && t <= sunset;
float p = isDay ? (t - sunrise) / (sunset - sunrise) : 0f; // 0 at sunrise .. 1 at sunset
float daylight = isDay ? MathF.Sin( p * MathF.PI ) : 0f; // 0 night .. 1 noon
float pAnchor = (cfg.AnchorHours - sunrise) / (sunset - sunrise);
float dlAnchor = MathF.Sin( pAnchor * MathF.PI );
float weatherDim = weather switch
{
WeatherKind.Rain => cfg.WeatherDimRain,
WeatherKind.Cloudy => cfg.WeatherDimCloudy,
_ => 1f,
};
if ( isDay )
{
// ── DAYTIME (anchor-exact by construction) ──
// ROTATION: pitch grows from the near-horizon value to the derived noon max, threading the derived
// anchor pitch; yaw sweeps east→west across the day, threading the anchor yaw.
float pitchSpan = (anchor.pitch - cfg.HorizonPitch) / dlAnchor;
float pitch = cfg.HorizonPitch + daylight * pitchSpan;
float yaw = anchor.yaw + (p - pAnchor) * cfg.YawSpan;
sunRot = Rotation.From( pitch, yaw, 0f );
// KEY COLOUR: lerp from the reference SunColor toward whiter noon / warmer horizon, anchored so the
// value EQUALS SunColor exactly at the anchor daylight.
float rel = daylight - dlAnchor; // 0 at anchor, + toward noon, - toward horizon
sunColor = rel >= 0f
? Color.Lerp( cfg.SunColor, cfg.NoonKey, dlAnchor < 1f ? rel / (1f - dlAnchor) : 0f )
: Color.Lerp( cfg.SunColor, cfg.HorizonKey, -rel / dlAnchor );
sunColor *= weatherDim;
// AMBIENT / SKY / ENVMAP: scale the reference grade by daylight (anchor == 1 == the exact reference).
float skyScale = dlAnchor > 0f ? MathF.Min( daylight / dlAnchor, 1.15f ) : 0f;
skyColor = cfg.SkyAmbient * skyScale;
skyTint = cfg.SkyTint * skyScale;
envTint = cfg.EnvmapTint * skyScale;
return;
}
// ── NIGHT + TWILIGHT. The fixed-low-moon night grade is the deep-night target; a TWILIGHT band
// TwilightHours past sunset (and before sunrise) smoothstep-lerps the sun rotation AND all four colours
// from the HORIZON-edge values (what the day arc reaches at sunrise/sunset, daylight→0) to the night
// values, so dusk reads as the sun continuing its arc below the horizon, not a hard flip. At w=0 it
// equals the day boundary (continuous with daytime); at w=1 it equals deep night. ──
float nightPitch = cfg.NightPitch;
float nightYaw = anchor.yaw + cfg.YawSpan * 0.6f; // fixed low moon direction
Color nightSun = cfg.NightKey;
Color nightSky = cfg.NightAmbient;
Color nightTint = cfg.NightSkyTint;
Color nightEnv = cfg.NightEnvTint;
// Twilight blend factor: 0 = horizon-edge look, 1 = deep night. Evening band (just after sunset) and the
// mirror morning band (just before sunrise); outside the bands it is 1 (deep night).
float tw = cfg.TwilightHours;
float w = 1f;
bool evening = t > sunset && t <= sunset + tw;
bool morning = t >= sunrise - tw && t < sunrise;
if ( evening ) w = Smoothstep( (t - sunset) / tw );
else if ( morning ) w = Smoothstep( (sunrise - t) / tw );
if ( w >= 1f )
{
// deep night (no blend), the fixed night grade.
sunRot = Rotation.From( nightPitch, nightYaw, 0f );
sunColor = nightSun;
skyColor = nightSky;
skyTint = nightTint;
envTint = nightEnv;
return;
}
// HORIZON-edge grade (the day arc evaluated at the sunrise/sunset boundary, daylight→0): pitch sits at the
// near-horizon value, yaw at that boundary's swept position, sun the deep warm HorizonKey, sky/ambient →0.
float boundaryP = evening ? 1f : 0f; // sunset p=1, sunrise p=0
float horizonPitch = cfg.HorizonPitch;
float horizonYaw = anchor.yaw + (boundaryP - pAnchor) * cfg.YawSpan;
Color horizonSun = cfg.HorizonKey * weatherDim;
sunRot = Rotation.From(
MathX.Lerp( horizonPitch, nightPitch, w ),
MathX.LerpDegrees( horizonYaw, nightYaw, w ),
0f );
sunColor = Color.Lerp( horizonSun, nightSun, w );
skyColor = Color.Lerp( Color.Black, nightSky, w ); // day-edge ambient is ~0; rise to the night floor
skyTint = Color.Lerp( Color.Black, nightTint, w );
envTint = Color.Lerp( Color.Black, nightEnv, w );
}
/// <summary>PURE: the clock-advance rate MULTIPLIER for a given hour-of-day (0..24), applied to the base
/// pace in <see cref="DayNightClock"/>. The daylight arc runs slower (<see cref="DayNightConfig.DayRateScale"/>)
/// so the day lasts longer, while night keeps rate 1 so night real-time is preserved exactly. The two ramps
/// live INSIDE the daylight window (a <see cref="DayNightConfig.TwilightHours"/>-wide smoothstep at each edge),
/// reaching rate 1 exactly at sunrise/sunset so there is no rate discontinuity at the night boundary. Bounded
/// to [DayRateScale, 1], pure, so host and every client derive the same rate and stay in lockstep.</summary>
public static float ClockRateScale( float hourOfDay, in DayNightConfig cfg )
{
float sunrise = cfg.SunriseHour, sunset = cfg.SunsetHour;
float tw = cfg.TwilightHours;
float dayScale = cfg.DayRateScale;
const float nightScale = 1f;
float t = hourOfDay - MathF.Floor( hourOfDay / 24f ) * 24f; // wrap to 0..24 for callers passing total hours
// Full daylight interior: the slow pace.
if ( t >= sunrise + tw && t <= sunset - tw ) return dayScale;
// Dawn ramp (inside the day window): rate 1 at sunrise → slow by sunrise+tw. Night stays exact because the
// ramp is spent within daylight, not stolen from the night arc.
if ( t >= sunrise && t < sunrise + tw ) return MathX.Lerp( nightScale, dayScale, Smoothstep( (t - sunrise) / tw ) );
// Dusk ramp (inside the day window): slow until sunset-tw → rate 1 exactly at sunset, matching night.
if ( t > sunset - tw && t <= sunset ) return MathX.Lerp( dayScale, nightScale, Smoothstep( (t - (sunset - tw)) / tw ) );
// Night: unchanged rate, so the night arc's real-time length is preserved exactly.
return nightScale;
}
/// <summary>Apply the computed grade to a specific sun/sky/envmap trio. Leaves exposure / shadows / fog
/// alone. Pass null for any of sky/env you do not have. This is the ONLY method here that writes engine
/// state; everything above is pure.</summary>
public static void ApplyGradeTo( DirectionalLight sun, SkyBox2D sky, EnvmapProbe env,
float total, WeatherKind weather, in DayNightConfig cfg )
{
if ( !sun.IsValid() ) return;
ComputeGrade( total, weather, cfg, out var rot, out var sunColor, out var skyColor, out var skyTint, out var envTint );
sun.WorldRotation = rot;
sun.LightColor = sunColor;
sun.SkyColor = skyColor;
if ( sky.IsValid() ) sky.Tint = skyTint;
if ( env.IsValid() ) env.TintColor = envTint;
}
}