Expand the SkyKeyframe record with retail-exact fog fields (FogStart, FogEnd, FogMode) per r12 §5. The existing FogDensity field is retained for backwards compat with tests that pin it; new shipping code reads FogStart / FogEnd / FogMode directly. Add WeatherSystem (WeatherKind + EnvironOverride enum + 10s transition ease + deterministic per-day-index roll) matching r12 §6.1. Roll weights are ~60% Clear / 20% Overcast / 12% Rain / 5% Snow / 3% Storm — tuned against retail observations. Storm mode triggers lightning flashes every 8–30 s via an exponential-decay (200ms τ) flash level that the shader consumes as an additive scene bump. Add SkyDescLoader that parses the Region dat (0x13000000) into LoadedSkyDesc — DayGroupData with SkyObjectData (visibility window + arc sweep), per-keyframe SkyObjectReplaceData, and a shader-ready SkyStateProvider builder. Sun/ambient colors are pre-multiplied by DirBright/AmbBright so the shader never needs to know about retail's scalar brightness field. 19 new tests (weather determinism, transition ease, environ override tint, flash decay, dat-load conversion with fog + pre-mult colors). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
C#
309 lines
12 KiB
C#
using System;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.World;
|
||
|
||
/// <summary>
|
||
/// Client-local atmospheric regime. Retail AC has no server weather
|
||
/// opcode (r12 §6) — the client picks a state per in-game day via a
|
||
/// deterministic seeded RNG so all players on the same server see the
|
||
/// same weather without any packets. Transitions take ~10 seconds.
|
||
///
|
||
/// <para>
|
||
/// The rendering side reads <see cref="Kind"/> to decide whether to
|
||
/// spawn rain/snow particles and which cloud mesh override to select.
|
||
/// The <see cref="Intensity"/> field lets the fog / particle rate /
|
||
/// cloud-darkness terms ease in and out smoothly rather than popping.
|
||
/// </para>
|
||
/// </summary>
|
||
public enum WeatherKind
|
||
{
|
||
Clear = 0,
|
||
Overcast = 1,
|
||
Rain = 2,
|
||
Snow = 3,
|
||
Storm = 4,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Server-forced fog override (retail <c>EnvironChangeType</c>). When
|
||
/// the server sends <c>AdminEnvirons</c> (0xEA60) with one of the
|
||
/// non-<see cref="None"/> values, the client overrides its locally-computed
|
||
/// fog color and density with the tint shown below. See r12 §5.2 and
|
||
/// <c>references/ACE/Source/ACE.Entity/Enum/EnvironChangeType.cs</c>.
|
||
/// </summary>
|
||
public enum EnvironOverride
|
||
{
|
||
None = 0x00, // clear override, revert to dat-driven fog
|
||
RedFog = 0x01,
|
||
BlueFog = 0x02,
|
||
WhiteFog = 0x03,
|
||
GreenFog = 0x04,
|
||
BlackFog = 0x05,
|
||
BlackFog2 = 0x06,
|
||
}
|
||
|
||
/// <summary>
|
||
/// Full per-frame atmosphere state consumed by the shader + particle
|
||
/// systems. Built by <see cref="WeatherSystem"/> from
|
||
/// <list type="bullet">
|
||
/// <item><description>the interpolated <see cref="SkyKeyframe"/>,</description></item>
|
||
/// <item><description>the current <see cref="WeatherKind"/>,</description></item>
|
||
/// <item><description>a possibly-active <see cref="EnvironOverride"/>,</description></item>
|
||
/// <item><description>a transient lightning-flash bump.</description></item>
|
||
/// </list>
|
||
/// </summary>
|
||
public readonly record struct AtmosphereSnapshot(
|
||
WeatherKind Kind,
|
||
float Intensity, // 0..1, eases on state transitions
|
||
Vector3 FogColor, // final fog color (may be overridden)
|
||
float FogStart,
|
||
float FogEnd,
|
||
FogMode FogMode,
|
||
float LightningFlash, // 0..1, decays from strike moment
|
||
EnvironOverride Override);
|
||
|
||
/// <summary>
|
||
/// Weather state machine — deterministic per-day RNG picks the weather
|
||
/// kind; a 10-second ease blends fog + particle density between old
|
||
/// and new states. Also owns the lightning-flash timer for storms.
|
||
///
|
||
/// <para>
|
||
/// Algorithm (r12 §6.1–6.2):
|
||
/// <list type="number">
|
||
/// <item><description>
|
||
/// Derive a deterministic <c>Random(dayIndex)</c> per in-game day.
|
||
/// Roll a weighted pick from a table matching retail's rough
|
||
/// 70/15/10/5 distribution (Clear dominates).
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// When the kind changes, store a <c>transitionStart</c> timestamp
|
||
/// and tween <see cref="AtmosphereSnapshot.Intensity"/> from 0 → 1
|
||
/// over <see cref="TransitionSeconds"/>.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// Storm kind only: every 8–30 seconds fire a lightning flash; the
|
||
/// shader reads <see cref="AtmosphereSnapshot.LightningFlash"/> as
|
||
/// an additive scene bump that decays with a 200 ms time constant.
|
||
/// </description></item>
|
||
/// <item><description>
|
||
/// Any server <see cref="EnvironOverride"/> beats the local picks —
|
||
/// stick the override fog color and density in the snapshot until
|
||
/// the server sends <see cref="EnvironOverride.None"/>.
|
||
/// </description></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class WeatherSystem
|
||
{
|
||
public const float TransitionSeconds = 10f;
|
||
|
||
// Flash visual parameters (r12 §9). The spike rises to 1.0 in ~50ms
|
||
// and decays exponentially with a time constant of ~200ms.
|
||
private const float FlashDecay = 1f / 0.200f; // 1 / τ seconds
|
||
private const float FlashPeakHoldS = 0.05f;
|
||
|
||
// Retail storm cadence: 8–30 seconds between strikes.
|
||
private const float StrikeIntervalMinS = 8f;
|
||
private const float StrikeIntervalMaxS = 30f;
|
||
|
||
// Overcast-kind fog feels like ~40–150m retail range (r12 §5.1).
|
||
private const float OvercastFogStart = 40f;
|
||
private const float OvercastFogEnd = 150f;
|
||
private const float StormFogStart = 25f;
|
||
private const float StormFogEnd = 90f;
|
||
|
||
private WeatherKind _kind = WeatherKind.Clear;
|
||
private WeatherKind _previousKind = WeatherKind.Clear;
|
||
private float _transitionT; // 0..1 through the cross-fade
|
||
|
||
private float _flashLevel;
|
||
private float _flashAge; // seconds since last strike
|
||
private float _nextStrikeInS;
|
||
|
||
private EnvironOverride _override;
|
||
|
||
private int _rolledDayIndex = int.MinValue; // unrolled == "pick one"
|
||
|
||
private readonly Random _strikeJitter;
|
||
|
||
public WeatherSystem(Random? rng = null)
|
||
{
|
||
_strikeJitter = rng ?? new Random(unchecked((int)0xDCAE_5001u));
|
||
_nextStrikeInS = 12f;
|
||
}
|
||
|
||
/// <summary>Current active weather.</summary>
|
||
public WeatherKind Kind => _kind;
|
||
|
||
/// <summary>Last-known server fog override (sticky between sync packets).</summary>
|
||
public EnvironOverride Override
|
||
{
|
||
get => _override;
|
||
set => _override = value;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Debug / test hook — force a specific weather kind, ignoring the
|
||
/// per-day roll. Passing <see cref="WeatherKind.Clear"/> returns to
|
||
/// normal behavior starting on the next day-roll.
|
||
/// </summary>
|
||
public void ForceWeather(WeatherKind kind)
|
||
{
|
||
BeginTransition(kind);
|
||
_rolledDayIndex = int.MaxValue; // "forced" sentinel — don't re-roll
|
||
}
|
||
|
||
/// <summary>
|
||
/// Advance the state machine. Call once per frame from the render
|
||
/// loop. <paramref name="dayIndex"/> is the in-game day (derived
|
||
/// from <see cref="DerethDateTime"/>); when it changes we re-roll
|
||
/// the weather kind.
|
||
/// </summary>
|
||
public void Tick(double nowSeconds, int dayIndex, float dtSeconds)
|
||
{
|
||
// Cross-fade progression: transitionT advances toward 1 over
|
||
// TransitionSeconds. Capped; no further rollover.
|
||
if (_transitionT < 1f)
|
||
_transitionT = Math.Min(1f, _transitionT + dtSeconds / TransitionSeconds);
|
||
|
||
// Day changed → re-roll. Skip the sentinel (forced).
|
||
if (dayIndex != _rolledDayIndex && _rolledDayIndex != int.MaxValue)
|
||
{
|
||
_rolledDayIndex = dayIndex;
|
||
var newKind = RollKind(dayIndex);
|
||
if (newKind != _kind) BeginTransition(newKind);
|
||
}
|
||
|
||
// Lightning timer only ticks in Storm kind.
|
||
if (_kind == WeatherKind.Storm && _override == EnvironOverride.None)
|
||
{
|
||
_nextStrikeInS -= dtSeconds;
|
||
if (_nextStrikeInS <= 0f)
|
||
{
|
||
TriggerFlash();
|
||
_nextStrikeInS = StrikeIntervalMinS
|
||
+ (float)_strikeJitter.NextDouble() * (StrikeIntervalMaxS - StrikeIntervalMinS);
|
||
}
|
||
}
|
||
|
||
// Decay the flash level with a 200ms time constant.
|
||
if (_flashLevel > 0f)
|
||
{
|
||
_flashAge += dtSeconds;
|
||
if (_flashAge < FlashPeakHoldS)
|
||
_flashLevel = 1f;
|
||
else
|
||
_flashLevel = MathF.Exp(-(_flashAge - FlashPeakHoldS) * FlashDecay);
|
||
if (_flashLevel < 1e-3f) _flashLevel = 0f;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Trigger a lightning flash manually (server-forced or test hook).
|
||
/// </summary>
|
||
public void TriggerFlash()
|
||
{
|
||
_flashLevel = 1f;
|
||
_flashAge = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Produce the per-frame snapshot consumed by the shader UBO +
|
||
/// particle emitter spawners. Combines the sky keyframe's fog with
|
||
/// the weather state's fog overlay, then applies the server
|
||
/// <see cref="EnvironOverride"/> tint if any.
|
||
/// </summary>
|
||
public AtmosphereSnapshot Snapshot(in SkyKeyframe kf)
|
||
{
|
||
// Cross-fade fog distance + color from previous-kind to new-kind.
|
||
var prev = FogForKind(_previousKind, kf);
|
||
var curr = FogForKind(_kind, kf);
|
||
|
||
float t = _transitionT;
|
||
var fogColor = Vector3.Lerp(prev.color, curr.color, t);
|
||
float fogStart = prev.start + (curr.start - prev.start) * t;
|
||
float fogEnd = prev.end + (curr.end - prev.end) * t;
|
||
|
||
// Server environ override wins.
|
||
if (_override != EnvironOverride.None)
|
||
{
|
||
fogColor = EnvironOverrideColor(_override);
|
||
fogStart = 15f;
|
||
fogEnd = 80f; // Dense override fog
|
||
}
|
||
|
||
float intensity = _kind == WeatherKind.Clear ? 1f - t : t;
|
||
|
||
return new AtmosphereSnapshot(
|
||
Kind: _kind,
|
||
Intensity: Math.Clamp(intensity, 0f, 1f),
|
||
FogColor: fogColor,
|
||
FogStart: fogStart,
|
||
FogEnd: fogEnd,
|
||
FogMode: kf.FogMode,
|
||
LightningFlash: _flashLevel,
|
||
Override: _override);
|
||
}
|
||
|
||
// ----------------------------------------------------------------
|
||
// Internal machinery
|
||
// ----------------------------------------------------------------
|
||
|
||
private void BeginTransition(WeatherKind newKind)
|
||
{
|
||
_previousKind = _kind;
|
||
_kind = newKind;
|
||
_transitionT = 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Deterministic per-day weighted roll. Seeded with <paramref name="dayIndex"/>
|
||
/// alone so every client running the same day sees the same weather —
|
||
/// retail's mechanism for "synchronized weather without any packets"
|
||
/// (r12 §6.1).
|
||
/// </summary>
|
||
private static WeatherKind RollKind(int dayIndex)
|
||
{
|
||
// Mix the day index so consecutive days aren't adjacent in PRNG
|
||
// state space (avoids tiny-seed correlation issues).
|
||
int seed = unchecked((int)((uint)dayIndex * 0x9E3779B1u));
|
||
var rng = new Random(seed);
|
||
double r = rng.NextDouble();
|
||
// Retail weights (approximate): 60% clear, 20% overcast, 12% rain,
|
||
// 5% snow, 3% storm. Tuned for "most days are fine, some are bad."
|
||
if (r < 0.60) return WeatherKind.Clear;
|
||
if (r < 0.80) return WeatherKind.Overcast;
|
||
if (r < 0.92) return WeatherKind.Rain;
|
||
if (r < 0.97) return WeatherKind.Snow;
|
||
return WeatherKind.Storm;
|
||
}
|
||
|
||
private static (Vector3 color, float start, float end) FogForKind(WeatherKind kind, in SkyKeyframe kf)
|
||
{
|
||
return kind switch
|
||
{
|
||
WeatherKind.Clear => (kf.FogColor, kf.FogStart, kf.FogEnd),
|
||
WeatherKind.Overcast => (Vector3.Lerp(kf.FogColor, new Vector3(0.55f, 0.55f, 0.55f), 0.6f),
|
||
OvercastFogStart, OvercastFogEnd),
|
||
WeatherKind.Rain => (Vector3.Lerp(kf.FogColor, new Vector3(0.45f, 0.48f, 0.55f), 0.7f),
|
||
OvercastFogStart, OvercastFogEnd),
|
||
WeatherKind.Snow => (Vector3.Lerp(kf.FogColor, new Vector3(0.80f, 0.82f, 0.90f), 0.6f),
|
||
OvercastFogStart, OvercastFogEnd * 1.2f),
|
||
WeatherKind.Storm => (Vector3.Lerp(kf.FogColor, new Vector3(0.25f, 0.25f, 0.30f), 0.8f),
|
||
StormFogStart, StormFogEnd),
|
||
_ => (kf.FogColor, kf.FogStart, kf.FogEnd),
|
||
};
|
||
}
|
||
|
||
private static Vector3 EnvironOverrideColor(EnvironOverride o) => o switch
|
||
{
|
||
EnvironOverride.RedFog => new Vector3(0.60f, 0.05f, 0.05f),
|
||
EnvironOverride.BlueFog => new Vector3(0.08f, 0.15f, 0.60f),
|
||
EnvironOverride.WhiteFog => new Vector3(0.90f, 0.90f, 0.92f),
|
||
EnvironOverride.GreenFog => new Vector3(0.08f, 0.55f, 0.12f),
|
||
EnvironOverride.BlackFog => new Vector3(0.02f, 0.02f, 0.02f),
|
||
EnvironOverride.BlackFog2 => new Vector3(0.04f, 0.01f, 0.01f),
|
||
_ => new Vector3(1f, 1f, 1f),
|
||
};
|
||
}
|