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>
This commit is contained in:
Erik 2026-04-27 15:02:35 +02:00
parent dbe6690a4e
commit 63b50c5291
5 changed files with 97 additions and 31 deletions

View file

@ -5225,9 +5225,11 @@ public sealed class GameWindow : IDisposable
}
else
{
// Outdoor: full keyframe sun + ambient; colors are already
// pre-multiplied by DirBright / AmbBright inside
// SkyDescLoader so we feed them straight into the UBO.
// Outdoor: full keyframe sun + ambient. The SkyKeyframe stores
// raw DirColor + DirBright (and AmbColor + AmbBright) for
// retail-faithful per-channel keyframe interpolation; the
// computed `kf.SunColor` / `kf.AmbientColor` properties return
// the post-multiplied product the shader expects.
Lighting.Sun = new AcDream.Core.Lighting.LightSource
{
Kind = AcDream.Core.Lighting.LightKind.Directional,

View file

@ -551,12 +551,23 @@ public static class SkyDescLoader
_ => FogMode.Off,
};
// Store DirColor / AmbColor RAW and DirBright / AmbBright SEPARATE
// (NOT pre-multiplied) so the keyframe interpolator can lerp each
// channel independently — matches retail SkyDesc::GetLighting at
// 0x00500ac9 (decomp lines 261317-261331). Multiplying at load
// time and lerping the product produces mathematically different
// results than retail when both color and brightness change
// between adjacent keyframes. The post-multiplied values are
// available via `kf.SunColor` / `kf.AmbientColor` computed
// properties for shader-uniform plumbing.
var kf = new SkyKeyframe(
Begin: s.Begin,
SunHeadingDeg: s.DirHeading,
SunPitchDeg: s.DirPitch,
SunColor: ColorToVec3(s.DirColor) * s.DirBright,
AmbientColor: ColorToVec3(s.AmbColor) * s.AmbBright,
DirColor: ColorToVec3(s.DirColor),
DirBright: s.DirBright,
AmbColor: ColorToVec3(s.AmbColor),
AmbBright: s.AmbBright,
FogColor: ColorToVec3(s.WorldFogColor),
FogDensity: 0f,
FogStart: s.MinWorldFog,

View file

@ -34,24 +34,53 @@ public enum FogMode
/// </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.
/// 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 SunColor, // RGB linear, post-brightness multiply
Vector3 AmbientColor, // RGB linear, post-brightness multiply
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);
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
@ -111,12 +140,18 @@ public sealed class SkyStateProvider
// 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,
SunColor: new Vector3(0.02f, 0.02f, 0.08f), // deep blue
AmbientColor: new Vector3(0.05f, 0.05f, 0.12f),
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,
@ -126,8 +161,10 @@ public sealed class SkyStateProvider
Begin: 0.25f,
SunHeadingDeg: 90f, // east at dawn
SunPitchDeg: 0f,
SunColor: new Vector3(1.0f, 0.7f, 0.4f), // sunrise warm
AmbientColor: new Vector3(0.4f, 0.35f, 0.3f),
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,
@ -137,8 +174,10 @@ public sealed class SkyStateProvider
Begin: 0.5f,
SunHeadingDeg: 180f, // south at noon
SunPitchDeg: 70f,
SunColor: new Vector3(1.0f, 0.98f, 0.95f), // bright white-ish
AmbientColor: new Vector3(0.5f, 0.5f, 0.55f),
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,
@ -148,8 +187,10 @@ public sealed class SkyStateProvider
Begin: 0.75f,
SunHeadingDeg: 270f, // west at dusk
SunPitchDeg: 0f,
SunColor: new Vector3(0.95f, 0.4f, 0.25f), // sunset red
AmbientColor: new Vector3(0.35f, 0.25f, 0.25f),
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,
@ -194,17 +235,25 @@ public sealed class SkyStateProvider
// Angular lerp for sun heading: pick shortest arc.
float heading = ShortestAngleLerp(k1.SunHeadingDeg, k2.SunHeadingDeg, u);
// Fog mode doesn't interpolate — pick k1's mode (retail uses Linear everywhere).
// 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),
SunColor: Vector3.Lerp(k1.SunColor, k2.SunColor, u),
AmbientColor: Vector3.Lerp(k1.AmbientColor, k2.AmbientColor, u),
FogColor: Vector3.Lerp(k1.FogColor, k2.FogColor, 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),
FogStart: Lerp(k1.FogStart, k2.FogStart, u),
FogEnd: Lerp(k1.FogEnd, k2.FogEnd, u),
FogMode: k1.FogMode);
}

View file

@ -56,8 +56,10 @@ public sealed class SkyStateTests
Begin: 0.5f,
SunHeadingDeg: 180f, // south
SunPitchDeg: 70f,
SunColor: Vector3.One,
AmbientColor: Vector3.One,
DirColor: Vector3.One,
DirBright: 1f,
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.One,
FogDensity: 0.001f);

View file

@ -58,8 +58,10 @@ public sealed class WorldTimeDebugTests
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 90f,
SunColor: System.Numerics.Vector3.One,
AmbientColor: System.Numerics.Vector3.One,
DirColor: System.Numerics.Vector3.One,
DirBright: 1f,
AmbColor: System.Numerics.Vector3.One,
AmbBright: 1f,
FogColor: System.Numerics.Vector3.Zero,
FogDensity: 0f),
});