fix(sky): A7 — correct sun-vector magnitude (ambient + sun were ~32% too bright)

Outdoor lighting was ~32% too bright (washed-out, weak shading). Live cdb on
retail (SmartBox::SetWorldAmbientLight + SkyDesc::GetLighting + LScape::sunlight,
binary matches refs/acclient.pdb) pinned it: at the SAME game time + DayGroup,
acdream's ambient COLOR matched retail exactly (the purple is correct, authored
per-time-of-day in the sky dat) but the LEVEL was 0.607 vs retail's 0.459.

level = AmbBright + 0.2·|sunVec|, both AmbBright=0.40, so acdream's |sunVec|≈1.06
vs retail's ≈0.30. Retail's LScape::sunlight read live = (0.2238, ~0, 0.00352),
magnitude 0.224 = DirBright, y≈0.

RetailSunVector had `y = cos(P)` (≈1) — the raw PRE-transform value SkyDesc::
GetLighting writes to arg5 (0x00500ac9), before LScape::set_sky_position's
world transform. acdream ported the un-transformed vector, so the y=cos(P)≈1
term inflated |sunVec| to ~1.06. That magnitude feeds BOTH the ambient boost
(SkyKeyframe.AmbientColor) AND the sun colour (SkyKeyframe.SunColor =
DirColor×|sunVec|), over-brightening the whole scene (terrain, objects, sky)
~30% and also pointing the sun the wrong way.

Fix: RetailSunVector = DirBright × (cos(P)·sin(H), cos(P)·cos(H), sin(P)) — the
world-space spherical form LScape::sunlight actually holds; |sunVec| == DirBright
for all H/P. After: acdream ambient (0.353,0.176,0.449) vs retail (0.360,0.180,
0.459) — within ~2%, user-confirmed "better outside". Sun direction also corrected
(was pointing ~North from the bad y term).

Tests updated to the cdb-verified values (the prior tests pinned the inflated
magnitude). 18/18 sky tests green. reference-retail-ambient-values memory updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-18 15:08:52 +02:00
parent 4345e77d62
commit 57c11358b6
3 changed files with 73 additions and 56 deletions

View file

@ -74,22 +74,15 @@ public readonly record struct SkyKeyframe(
/// (see <see cref="SkyStateProvider.RetailSunVector"/>).
///
/// <para>
/// Why <c>|sunVec|</c> instead of <c>DirBright</c> directly: retail's
/// <c>PrimD3DRender::UpdateLightsInternal</c> at <c>0x0059b57c</c>
/// (decomp line 424118-424119) computes
/// <code>D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²)</code>
/// from the sun vector <c>SkyDesc::GetLighting</c> built at
/// <c>0x00500ac9</c> (decomp lines 261343-261353):
/// <code>
/// sunVec.x = sin(H) × DirBright × cos(P)
/// sunVec.y = cos(P) // NOT scaled by DirBright
/// sunVec.z = DirBright × sin(P)
/// </code>
/// Because Y is unscaled by <c>DirBright</c>, <c>|sunVec|</c> ≠
/// <c>DirBright</c> in general — it varies with sun pitch and heading.
/// Using <c>DirBright</c> alone underweighted the warm directional
/// term, letting the cool ambient/fog dominate ⇒ acdream rendered
/// blue-white at keyframes where retail looked warm-gray.
/// <c>|sunVec|</c> is retail's <c>D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²)</c>
/// scaling (<c>PrimD3DRender::UpdateLightsInternal</c> 0x0059b57c, decomp
/// 424118-424119) of the WORLD-space sun vector (<c>LScape::sunlight</c>).
/// Because <see cref="SkyStateProvider.RetailSunVector"/> is now the
/// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified —
/// see that method), <c>|sunVec| == DirBright</c>, so this is effectively
/// <c>SunColor = DirColor × DirBright</c>. (A prior bug used the un-transformed
/// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~45× too bright at dawn/dusk;
/// [[reference-retail-ambient-values]].)
/// </para>
/// </summary>
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
@ -301,21 +294,35 @@ public sealed class SkyStateProvider
}
/// <summary>
/// Retail's raw sun vector (NOT normalized) — the same vector
/// <c>SkyDesc::GetLighting</c> writes at <c>0x00500ac9</c>
/// (decomp lines 261343, 261352, 261353):
/// Retail's world-space sun vector (NOT normalized): the standard
/// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by
/// <c>DirBright</c>:
/// <code>
/// sunVec.x = sin(H_rad) × DirBright × cos(P_rad)
/// sunVec.y = cos(P_rad) // NOT scaled by DirBright
/// sunVec.z = DirBright × sin(P_rad)
/// sunVec.x = DirBright × cos(P) × sin(H)
/// sunVec.y = DirBright × cos(P) × cos(H)
/// sunVec.z = DirBright × sin(P)
/// </code>
/// Y is unscaled by brightness on purpose — that's what makes
/// <c>|sunVec|</c> ≠ <c>DirBright</c> in general (the magnitude varies
/// with pitch/heading, which is the basis for retail's "sun is brighter
/// in some configurations than others" lighting behavior). The shader's
/// <c>uSunDir</c> uniform uses the NORMALIZED vector for N·L; the
/// magnitude feeds <see cref="SkyKeyframe.SunColor"/> intensity and
/// the ambient brightness boost in <see cref="SkyKeyframe.AmbientColor"/>.
/// so <c>|sunVec| == DirBright</c> exactly (cos²P·(sin²H+cos²H)+sin²P = 1).
///
/// <para>
/// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]):
/// retail's <c>LScape::sunlight</c> read at a dawn keyframe (H=90°, P=0.9°,
/// DirBright≈0.224) = <c>(0.2238, ~0, 0.00352)</c> — y≈0, magnitude 0.224 =
/// DirBright. That fed <c>level = 0.2·|sunlight| + ambient_level = 0.2·0.224 +
/// 0.40 = 0.445</c>, matching the captured <c>SetWorldAmbientLight</c> level.
/// </para>
/// <para>
/// PRIOR BUG: an earlier version returned <c>y = cos(P)</c> (≈1) — the raw
/// PRE-transform value the decomp's <c>SkyDesc::GetLighting</c> writes to its
/// <c>arg5</c> (0x00500ac9, before <c>LScape::set_sky_position</c>'s world
/// transform). Porting that un-transformed vector inflated <c>|sunVec|</c> to
/// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost
/// (<see cref="SkyKeyframe.AmbientColor"/>) AND the sun colour
/// (<see cref="SkyKeyframe.SunColor"/>) by ~30% vs retail. The world-space
/// form above is what <c>LScape::sunlight</c> actually holds at runtime.
/// </para>
/// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright)
/// feeds the sun-colour intensity and the ambient brightness boost.
/// </summary>
public static Vector3 RetailSunVector(SkyKeyframe kf)
{
@ -325,9 +332,9 @@ public sealed class SkyStateProvider
float sinP = MathF.Sin(p);
float B = kf.DirBright;
return new Vector3(
MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P)
cosP, // y = cos(P) ← unscaled by B
B * sinP); // z = B × sin(P)
B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H)
B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H)
B * sinP); // z = DirBright × sin(P)
}
/// <summary>

View file

@ -100,22 +100,23 @@ public sealed class SkyDescLoaderTests
{
// The loader stores DirColor and DirBright RAW. The SunColor property
// composes them via |sunVec| per retail's UpdateLightsInternal at
// 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²)
// where the sun vector is built from heading/pitch/brightness with
// Y unscaled by brightness (decomp 261352).
// 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|.
// cdb-verified (reference-retail-ambient-values): |LScape::sunlight| ==
// DirBright for every keyframe (world-space spherical vector, magnitude
// DirBright·sqrt(cos²P+sin²P) = DirBright).
//
// For this region: H=180°, P=70°, B=1.5
// sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70))
// = (0, 0.342, 1.410)
// |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509
// sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70))
// = (0, -0.513, 1.410)
// |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright)
// DirColor.X = 200/255 = 0.7843
// SunColor.X = 0.7843 × 1.4509 = 1.138
// SunColor.X = 0.7843 × 1.500 = 1.1765
var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200);
var loaded = SkyDescLoader.LoadFromRegion(region);
Assert.NotNull(loaded);
var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe;
Assert.InRange(kf.SunColor.X, 1.13f, 1.15f);
Assert.InRange(kf.SunColor.X, 1.17f, 1.18f);
}
[Fact]

View file

@ -66,24 +66,33 @@ public sealed class SkyStateTests
}
[Fact]
public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne()
public void RetailSunVector_MagnitudeAlwaysEqualsDirBright()
{
// Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0.
// sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0)
// |sunVec| = 1 regardless of B (because Y is unscaled by B)
var kf = new SkyKeyframe(
Begin: 0f,
SunHeadingDeg: 0f,
SunPitchDeg: 0f,
DirColor: Vector3.One,
DirBright: 2.0f, // anything
AmbColor: Vector3.One,
AmbBright: 1f,
FogColor: Vector3.One,
FogDensity: 0f);
// cdb-verified (2026-06-18, reference-retail-ambient-values): retail's
// world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP),
// whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright
// for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the
// horizon — that was the ~30% over-bright bug.)
// Horizon north (H=0°, P=0°): (0, B, 0), |.| = B.
var horizon = new SkyKeyframe(
Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f,
DirColor: Vector3.One, DirBright: 2.0f,
AmbColor: Vector3.One, AmbBright: 1f,
FogColor: Vector3.One, FogDensity: 0f);
Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f);
var v = SkyStateProvider.RetailSunVector(kf);
Assert.InRange(v.Length(), 0.99f, 1.01f);
// Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224
// → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright.
var dawn = new SkyKeyframe(
Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f,
DirColor: Vector3.One, DirBright: 0.224f,
AmbColor: Vector3.One, AmbBright: 0.40f,
FogColor: Vector3.One, FogDensity: 0f);
var v = SkyStateProvider.RetailSunVector(dawn);
Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224
Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1)
Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035
Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright
}
[Fact]