feat(world): Phase G.1 data model — dat-accurate SkyKeyframe + WeatherSystem
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>
This commit is contained in:
parent
63b6922fc2
commit
0df1c5b4a6
5 changed files with 982 additions and 31 deletions
|
|
@ -5,25 +5,53 @@ using System.Numerics;
|
|||
namespace AcDream.Core.World;
|
||||
|
||||
/// <summary>
|
||||
/// One sky keyframe — the lighting + fog state for a specific day-fraction.
|
||||
/// Multiple keyframes across [0, 1) interpolate linearly (with angular
|
||||
/// wrap on sun direction) to produce the current sky state.
|
||||
/// Fog modes mirroring retail's <c>D3DFOGMODE</c>. Retail only ever uses
|
||||
/// <see cref="Off"/> and <see cref="Linear"/>; the Exp variants are
|
||||
/// supported by the dat schema but never appear in shipped data. See r12
|
||||
/// §5 and <c>SkyTimeOfDay.WorldFog</c> (dat <c>uint</c>).
|
||||
/// </summary>
|
||||
public enum FogMode
|
||||
{
|
||||
Off = 0,
|
||||
Linear = 1,
|
||||
Exp = 2,
|
||||
Exp2 = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One sky keyframe — the full lighting + fog state for a specific
|
||||
/// day-fraction. Multiple keyframes across <c>[0, 1)</c> interpolate
|
||||
/// linearly (with angular-shortest-arc wrap on sun direction) to produce
|
||||
/// the current sky state.
|
||||
///
|
||||
/// <para>
|
||||
/// Retail's <c>SkyTimeOfDay</c> dat struct carries this exact data plus
|
||||
/// references to sky objects (sun mesh, moon mesh, cloud layer) which
|
||||
/// belong to the renderer. This class exposes the lighting-relevant
|
||||
/// subset — sun direction, sun color, ambient color, fog.
|
||||
/// belong to the renderer. This record exposes the shader-relevant
|
||||
/// subset — sun direction, sun color, ambient color, linear fog. See
|
||||
/// <c>references/DatReaderWriter/DatReaderWriter/Generated/Types/SkyTimeOfDay.generated.cs</c>
|
||||
/// and r12 §4 + §5.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Colors are in LINEAR RGB, already pre-multiplied by their brightness
|
||||
/// scalar so the shader can plug them straight into the UBO without
|
||||
/// knowing about <c>DirBright</c> / <c>AmbBright</c>. Range is loosely
|
||||
/// [0, N] — retail dusk tints have channels above 1.0 and the frag
|
||||
/// shader clamps after lighting math.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct SkyKeyframe(
|
||||
float Begin, // [0, 1] day-fraction this keyframe kicks in
|
||||
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
|
||||
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
|
||||
Vector3 SunColor, // RGB linear, post-brightness multiply
|
||||
Vector3 AmbientColor,
|
||||
float Begin, // [0, 1] day-fraction this keyframe kicks in
|
||||
float SunHeadingDeg, // compass heading (0=N, 90=E, 180=S, 270=W)
|
||||
float SunPitchDeg, // elevation above horizon (-90=below, +90=zenith)
|
||||
Vector3 SunColor, // RGB linear, post-brightness multiply
|
||||
Vector3 AmbientColor, // RGB linear, post-brightness multiply
|
||||
Vector3 FogColor,
|
||||
float FogDensity);
|
||||
float FogDensity, // retained for tests; derive from FogStart/End
|
||||
float FogStart = 80f, // meters (retail default ~120 clear, ~40 storm)
|
||||
float FogEnd = 350f, // meters (retail default ~350 clear, ~150 storm)
|
||||
FogMode FogMode = FogMode.Linear);
|
||||
|
||||
/// <summary>
|
||||
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
|
||||
|
|
@ -42,9 +70,8 @@ public readonly record struct SkyKeyframe(
|
|||
/// with wrap handling.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// Lerp every vector component; SLERP the sun direction
|
||||
/// quaternions to avoid artifacts when heading wraps (e.g. k1.Heading
|
||||
/// = 350°, k2.Heading = 10°).
|
||||
/// Lerp every vector component; use shortest-arc lerp for the sun
|
||||
/// heading so k1=350° → k2=10° doesn't sweep backwards across the sky.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
|
|
@ -64,12 +91,20 @@ public sealed class SkyStateProvider
|
|||
}
|
||||
|
||||
public int KeyframeCount => _keyframes.Count;
|
||||
public IReadOnlyList<SkyKeyframe> Keyframes => _keyframes;
|
||||
|
||||
/// <summary>
|
||||
/// Default keyframe set based on retail observations — sunrise at 6am,
|
||||
/// noon at 12pm, sunset at 6pm. Used when the dat-loaded set isn't
|
||||
/// available yet or the player is in a region whose Region dat
|
||||
/// doesn't override it.
|
||||
///
|
||||
/// <para>
|
||||
/// Fog values approximate retail clear-weather defaults: ~80m..~350m
|
||||
/// linear fog with color matching the horizon band so mountains at
|
||||
/// distance fade into the sky instead of popping at the clip plane.
|
||||
/// See r12 §5.1.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static SkyStateProvider Default()
|
||||
{
|
||||
|
|
@ -83,7 +118,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
|
||||
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
|
||||
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
|
||||
FogDensity: 0.004f),
|
||||
FogDensity: 0.004f,
|
||||
FogStart: 30f,
|
||||
FogEnd: 180f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.25f,
|
||||
SunHeadingDeg: 90f, // east at dawn
|
||||
|
|
@ -91,7 +129,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
|
||||
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
|
||||
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
|
||||
FogDensity: 0.002f),
|
||||
FogDensity: 0.002f,
|
||||
FogStart: 60f,
|
||||
FogEnd: 260f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.5f,
|
||||
SunHeadingDeg: 180f, // south at noon
|
||||
|
|
@ -99,7 +140,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
|
||||
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
|
||||
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
|
||||
FogDensity: 0.0008f),
|
||||
FogDensity: 0.0008f,
|
||||
FogStart: 120f,
|
||||
FogEnd: 500f,
|
||||
FogMode: FogMode.Linear),
|
||||
new SkyKeyframe(
|
||||
Begin: 0.75f,
|
||||
SunHeadingDeg: 270f, // west at dusk
|
||||
|
|
@ -107,7 +151,10 @@ public sealed class SkyStateProvider
|
|||
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
|
||||
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
|
||||
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
|
||||
FogDensity: 0.002f),
|
||||
FogDensity: 0.002f,
|
||||
FogStart: 60f,
|
||||
FogEnd: 260f,
|
||||
FogMode: FogMode.Linear),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -145,21 +192,34 @@ public sealed class SkyStateProvider
|
|||
u = Math.Clamp(u, 0f, 1f);
|
||||
|
||||
// Angular lerp for sun heading: pick shortest arc.
|
||||
float h1 = k1.SunHeadingDeg;
|
||||
float h2 = k2.SunHeadingDeg;
|
||||
float delta = h2 - h1;
|
||||
while (delta > 180f) delta -= 360f;
|
||||
while (delta < -180f) delta += 360f;
|
||||
float heading = h1 + delta * u;
|
||||
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
|
||||
|
||||
// Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
|
||||
return new SkyKeyframe(
|
||||
Begin: t,
|
||||
Begin: t,
|
||||
SunHeadingDeg: heading,
|
||||
SunPitchDeg: k1.SunPitchDeg + (k2.SunPitchDeg - k1.SunPitchDeg) * u,
|
||||
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
|
||||
SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
|
||||
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
|
||||
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, u),
|
||||
FogDensity: k1.FogDensity + (k2.FogDensity - k1.FogDensity) * u);
|
||||
FogDensity: Lerp(k1.FogDensity, k2.FogDensity, u),
|
||||
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
|
||||
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
|
||||
FogMode: k1.FogMode);
|
||||
}
|
||||
|
||||
private static float Lerp(float a, float b, float u) => a + (b - a) * u;
|
||||
|
||||
/// <summary>
|
||||
/// Shortest-arc heading lerp: r12 §4. If <c>a=350</c> and <c>b=10</c>
|
||||
/// the lerp walks 20° forward through 0° rather than 340° backward.
|
||||
/// </summary>
|
||||
public static float ShortestAngleLerp(float aDeg, float bDeg, float u)
|
||||
{
|
||||
float delta = bDeg - aDeg;
|
||||
while (delta > 180f) delta -= 360f;
|
||||
while (delta < -180f) delta += 360f;
|
||||
return aDeg + delta * u;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -185,42 +245,89 @@ public sealed class SkyStateProvider
|
|||
/// Service that turns server-delivered tick counts into live sky state.
|
||||
/// Owns the "current time" clock (seeded from server sync, advanced by
|
||||
/// real-time elapsed between syncs).
|
||||
///
|
||||
/// <para>
|
||||
/// Supports a debug "time override" (slash-command <c>/time 0.5</c>) that
|
||||
/// forces a specific day fraction regardless of server sync — used for
|
||||
/// screenshots and visual debugging. The override is transient and gets
|
||||
/// cleared on the next TimeSync packet.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class WorldTimeService
|
||||
{
|
||||
private readonly SkyStateProvider _sky;
|
||||
private SkyStateProvider _sky;
|
||||
private double _lastSyncedTicks;
|
||||
private DateTime _lastSyncedWallClockUtc = DateTime.UtcNow;
|
||||
|
||||
private float? _debugDayFractionOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Rate at which in-game time advances relative to real time. Retail
|
||||
/// default is 1.0 (one wall-clock second = one in-game tick). Server
|
||||
/// config can override via <c>SkyDesc.TickSize</c>; see r12 §1.2.
|
||||
/// </summary>
|
||||
public double TickSize { get; set; } = 1.0;
|
||||
|
||||
public WorldTimeService(SkyStateProvider sky)
|
||||
{
|
||||
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot-swap the keyframe source — typically called once at world-load
|
||||
/// time after the Region dat has been parsed by <see cref="SkyDescLoader"/>.
|
||||
/// </summary>
|
||||
public void SetProvider(SkyStateProvider sky)
|
||||
{
|
||||
_sky = sky ?? throw new ArgumentNullException(nameof(sky));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the authoritative tick count from a server TimeSync packet.
|
||||
/// Clears any debug override.
|
||||
/// </summary>
|
||||
public void SyncFromServer(double serverTicks)
|
||||
{
|
||||
_lastSyncedTicks = serverTicks;
|
||||
_lastSyncedWallClockUtc = DateTime.UtcNow;
|
||||
_debugDayFractionOverride = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Debug-only: force a specific day fraction in [0, 1). Overrides
|
||||
/// server-synced time until cleared by <see cref="SyncFromServer"/>
|
||||
/// or <see cref="ClearDebugTime"/>.
|
||||
/// </summary>
|
||||
public void SetDebugTime(float dayFraction)
|
||||
{
|
||||
_debugDayFractionOverride = dayFraction;
|
||||
}
|
||||
|
||||
public void ClearDebugTime() => _debugDayFractionOverride = null;
|
||||
|
||||
/// <summary>
|
||||
/// Current ticks at <see cref="DateTime.UtcNow"/>, advanced from the
|
||||
/// last sync by real-time elapsed seconds.
|
||||
/// last sync by real-time elapsed seconds times <see cref="TickSize"/>.
|
||||
/// </summary>
|
||||
public double NowTicks
|
||||
{
|
||||
get
|
||||
{
|
||||
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
|
||||
return _lastSyncedTicks + elapsed;
|
||||
return _lastSyncedTicks + elapsed * TickSize;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current day fraction in [0, 1).</summary>
|
||||
public double DayFraction => DerethDateTime.DayFraction(NowTicks);
|
||||
public double DayFraction
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_debugDayFractionOverride.HasValue)
|
||||
return _debugDayFractionOverride.Value;
|
||||
return DerethDateTime.DayFraction(NowTicks);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Current sky lighting state.</summary>
|
||||
public SkyKeyframe CurrentSky => _sky.Interpolate((float)DayFraction);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue