acdream/src/AcDream.Core/World/SkyState.cs
Erik 63b50c5291 fix(sky): retail-faithful keyframe lerp — separate-channel color/bright
Retail's SkyDesc::GetLighting at 0x00500ac9 (decomp lines 261317-261331)
lerps each color channel and the brightness scalar SEPARATELY, then
multiplies post-lerp:

  arg4.r = lerp(k1.amb_color.r, k2.amb_color.r, u)
  arg4.g = lerp(k1.amb_color.g, k2.amb_color.g, u)
  arg4.b = lerp(k1.amb_color.b, k2.amb_color.b, u)
  arg3   = lerp(k1.amb_bright, k2.amb_bright, u)
  final  = (arg4.rgb * arg3, ...)

acdream pre-multiplied (color × bright) at LOAD time
(`SkyDescLoader.cs:558-559`) and then lerped the product. For any
keyframe pair where both color and brightness change, the two are
mathematically distinct. Example, k1=(white, b=0.5) k2=(black, b=1.0)
at u=0.5:
  - retail: color=gray(0.5), bright=0.75 → final = (0.375, 0.375, 0.375)
  - acdream: lerp((0.5,0.5,0.5), (0,0,0), 0.5) = (0.25, 0.25, 0.25)

For Rainy/Cloudy DayGroups transitioning between dim and bright
keyframes, this contributes to subtle brightness divergence vs retail.

Refactor:
  SkyKeyframe stores DirColor / DirBright / AmbColor / AmbBright
    SEPARATELY (raw, not pre-multiplied).
  Computed properties SunColor and AmbientColor return the
    post-multiplied product, keeping the shader uniform interface
    (uSunColor / uAmbientColor) unchanged.
  SkyStateProvider.Interpolate lerps each raw channel, then constructs
    a new SkyKeyframe whose computed properties yield the correct
    post-lerp multiply.
  SkyDescLoader now stores raw values without pre-multiplying.
  GameWindow comment updated; no functional change there.
  Default factory + tests updated to use the new constructor parameters
    with DirBright=AmbBright=1.0 (preserving exact existing behavior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:02:35 +02:00

401 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.World;
/// <summary>
/// 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 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 stored RAW (NOT pre-multiplied by brightness) in
/// <see cref="DirColor"/> / <see cref="AmbColor"/> with the brightness
/// scalars in <see cref="DirBright"/> / <see cref="AmbBright"/>. Retail's
/// <c>SkyDesc::GetLighting</c> at <c>0x00500ac9</c> (decomp lines
/// 261317-261331) lerps each channel separately and lerps brightness
/// separately, then multiplies post-lerp. Lerping the pre-multiplied
/// product gives mathematically different results when both color and
/// brightness change between adjacent keyframes — the cause of subtle
/// brightness discrepancies vs retail observed in dual-client
/// comparisons (Issue #3 visual sub-bug, 2026-04-27).
/// </para>
/// <para>
/// The computed properties <see cref="SunColor"/> and
/// <see cref="AmbientColor"/> return the post-multiplied product, so
/// downstream shader uniform plumbing (sky.vert / mesh.vert /
/// SceneLightingUbo) is unchanged.
/// </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 DirColor, // RGB linear, RAW (NOT × DirBright)
float DirBright, // sun brightness multiplier
Vector3 AmbColor, // RGB linear, RAW (NOT × AmbBright)
float AmbBright, // ambient brightness multiplier
Vector3 FogColor,
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>
/// Final directional sun color used by the shader =
/// <see cref="DirColor"/> × <see cref="DirBright"/>. Computed property
/// so the storage stays as separate channels (for retail-faithful
/// keyframe interpolation) while the shader interface stays simple.
/// </summary>
public Vector3 SunColor => DirColor * DirBright;
/// <summary>
/// Final ambient color used by the shader =
/// <see cref="AmbColor"/> × <see cref="AmbBright"/>. See
/// <see cref="SunColor"/> for the rationale.
/// </summary>
public Vector3 AmbientColor => AmbColor * AmbBright;
}
/// <summary>
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
/// the blended lighting state between the surrounding keyframes.
///
/// <para>
/// Math (r12 §4):
/// <list type="number">
/// <item><description>
/// Pick the two keyframes bracketing <c>t</c>: <c>k1</c> = last
/// keyframe with <c>Begin &lt;= t</c>, <c>k2</c> = next keyframe
/// (wraps: if <c>k1</c> is last, <c>k2</c> is first).
/// </description></item>
/// <item><description>
/// Local blend <c>u = (t - k1.Begin) / (k2.Begin - k1.Begin)</c>
/// with wrap handling.
/// </description></item>
/// <item><description>
/// 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>
/// </summary>
public sealed class SkyStateProvider
{
private readonly List<SkyKeyframe> _keyframes;
public SkyStateProvider(IReadOnlyList<SkyKeyframe> keyframes)
{
if (keyframes is null || keyframes.Count == 0)
throw new ArgumentException("At least one keyframe required", nameof(keyframes));
// Sort by Begin so the walk is deterministic regardless of input order.
var sorted = new List<SkyKeyframe>(keyframes);
sorted.Sort((a, b) => a.Begin.CompareTo(b.Begin));
_keyframes = sorted;
}
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()
{
// Day fractions: 0.0=midnight, 0.25=dawn, 0.5=noon, 0.75=dusk.
return new SkyStateProvider(new[]
{
// Default factory: brightness scalars are 1.0 here — the colors
// ARE the final intended values. Live Dereth keyframes loaded
// from the dat have separate non-1.0 DirBright/AmbBright values
// and the renderer multiplies them post-lerp.
new SkyKeyframe(
Begin: 0.0f,
SunHeadingDeg: 0f, // below horizon (north)
SunPitchDeg: -30f,
DirColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
DirBright: 1.0f,
AmbColor: new Vector3(0.05f, 0.05f, 0.12f),
AmbBright: 1.0f,
FogColor: new Vector3(0.02f, 0.02f, 0.05f),
FogDensity: 0.004f,
FogStart: 30f,
FogEnd: 180f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn
SunPitchDeg: 0f,
DirColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
DirBright: 1.0f,
AmbColor: new Vector3(0.4f, 0.35f, 0.3f),
AmbBright: 1.0f,
FogColor: new Vector3(0.8f, 0.55f, 0.4f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon
SunPitchDeg: 70f,
DirColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
DirBright: 1.0f,
AmbColor: new Vector3(0.5f, 0.5f, 0.55f),
AmbBright: 1.0f,
FogColor: new Vector3(0.7f, 0.75f, 0.85f),
FogDensity: 0.0008f,
FogStart: 120f,
FogEnd: 500f,
FogMode: FogMode.Linear),
new SkyKeyframe(
Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk
SunPitchDeg: 0f,
DirColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
DirBright: 1.0f,
AmbColor: new Vector3(0.35f, 0.25f, 0.25f),
AmbBright: 1.0f,
FogColor: new Vector3(0.85f, 0.45f, 0.35f),
FogDensity: 0.002f,
FogStart: 60f,
FogEnd: 260f,
FogMode: FogMode.Linear),
});
}
/// <summary>
/// Current interpolated sky state at day fraction <paramref name="t"/>.
/// Wraps correctly across the day boundary (1.0 → 0.0).
/// </summary>
public SkyKeyframe Interpolate(float t)
{
t = (float)(t - Math.Floor(t)); // wrap to [0, 1)
// Find k1: last keyframe with Begin <= t.
int k1Index = _keyframes.Count - 1;
for (int i = 0; i < _keyframes.Count; i++)
{
if (_keyframes[i].Begin <= t)
k1Index = i;
else
break;
}
int k2Index = (k1Index + 1) % _keyframes.Count;
var k1 = _keyframes[k1Index];
var k2 = _keyframes[k2Index];
// Compute blend weight, handling wrap (k1 is last, k2 is first).
float k1Begin = k1.Begin;
float k2Begin = k2.Begin;
if (k2Begin <= k1Begin) k2Begin += 1.0f; // unroll wrap
float tWrapped = t;
if (tWrapped < k1Begin) tWrapped += 1.0f;
float span = Math.Max(1e-6f, k2Begin - k1Begin);
float u = (tWrapped - k1Begin) / span;
u = Math.Clamp(u, 0f, 1f);
// Angular lerp for sun heading: pick shortest arc.
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
// Retail-faithful interpolation: lerp DirColor / DirBright /
// AmbColor / AmbBright as SEPARATE CHANNELS, not as the
// pre-multiplied product. Mirrors SkyDesc::GetLighting at
// 0x00500ac9 (decomp lines 261317-261331). The post-multiplied
// SunColor / AmbientColor are computed properties on the result.
// Fog mode doesn't interpolate — pick k1's mode (retail uses
// Linear everywhere).
return new SkyKeyframe(
Begin: t,
SunHeadingDeg: heading,
SunPitchDeg: Lerp(k1.SunPitchDeg, k2.SunPitchDeg, u),
DirColor: Vector3.Lerp(k1.DirColor, k2.DirColor, u),
DirBright: Lerp(k1.DirBright, k2.DirBright, u),
AmbColor: Vector3.Lerp(k1.AmbColor, k2.AmbColor, u),
AmbBright: Lerp(k1.AmbBright, k2.AmbBright, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, 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>
/// World-space sun direction unit vector pointing FROM the surface
/// TOWARDS the sun. Derived from heading + pitch in the returned
/// keyframe — shader sunDir uniform should use -this so lighting
/// math (N·L) works correctly for the side facing the sun.
/// </summary>
public static Vector3 SunDirectionFromKeyframe(SkyKeyframe kf)
{
float yaw = kf.SunHeadingDeg * (MathF.PI / 180f);
float pit = kf.SunPitchDeg * (MathF.PI / 180f);
// Heading 0 = +Y (north), +X=east. Pitch up from horizon.
float cosP = MathF.Cos(pit);
return new Vector3(
MathF.Sin(yaw) * cosP,
MathF.Cos(yaw) * cosP,
MathF.Sin(pit));
}
}
/// <summary>
/// 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 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 = System.DateTime.UtcNow;
_debugDayFractionOverride = null;
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_SKY") == "1")
{
var df = DerethDateTime.DayFraction(serverTicks);
var cal = DerethDateTime.ToCalendar(serverTicks);
System.Console.WriteLine(
$"[sky-dump] SyncFromServer: ticks={serverTicks:F1} dayFraction={df:F4} " +
$"calendar=PY{cal.Year} {cal.Month} {cal.Day} {cal.Hour}");
}
}
/// <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 times <see cref="TickSize"/>.
/// </summary>
public double NowTicks
{
get
{
double elapsed = (DateTime.UtcNow - _lastSyncedWallClockUtc).TotalSeconds;
return _lastSyncedTicks + elapsed * TickSize;
}
}
/// <summary>Current day fraction in [0, 1).</summary>
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);
/// <summary>Convenience: current sun direction from derived sky state.</summary>
public Vector3 CurrentSunDirection =>
SkyStateProvider.SunDirectionFromKeyframe(CurrentSky);
public DerethDateTime.Calendar CurrentCalendar =>
DerethDateTime.ToCalendar(NowTicks);
public bool IsDaytime => DerethDateTime.IsDaytime(NowTicks);
}