diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d0d1028..3fa1721 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -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,
diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs
index 409d51e..5ef8245 100644
--- a/src/AcDream.Core/World/SkyDescLoader.cs
+++ b/src/AcDream.Core/World/SkyDescLoader.cs
@@ -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,
diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs
index 94e1ab5..31fdf3b 100644
--- a/src/AcDream.Core/World/SkyState.cs
+++ b/src/AcDream.Core/World/SkyState.cs
@@ -34,24 +34,53 @@ public enum FogMode
///
///
///
-/// 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 DirBright / AmbBright. 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
+/// / with the brightness
+/// scalars in / . Retail's
+/// SkyDesc::GetLighting at 0x00500ac9 (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).
+///
+///
+/// The computed properties and
+/// return the post-multiplied product, so
+/// downstream shader uniform plumbing (sky.vert / mesh.vert /
+/// SceneLightingUbo) is unchanged.
///
///
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)
+{
+ ///
+ /// Final directional sun color used by the shader =
+ /// × . Computed property
+ /// so the storage stays as separate channels (for retail-faithful
+ /// keyframe interpolation) while the shader interface stays simple.
+ ///
+ public Vector3 SunColor => DirColor * DirBright;
+
+ ///
+ /// Final ambient color used by the shader =
+ /// × . See
+ /// for the rationale.
+ ///
+ public Vector3 AmbientColor => AmbColor * AmbBright;
+}
///
/// 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);
}
diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs
index 272bdc5..7eac91a 100644
--- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs
+++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs
@@ -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);
diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
index b1d6c24..7acf0d1 100644
--- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
+++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs
@@ -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),
});