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), });