Merge branch 'feature/sky-fixes' — sky/weather rendering retail-faithful pass
Six commits on the branch, three retail-decomp investigations (in-house + two external code-review agents) converging on the same root causes:97fc1b5fix(sky): translucency-as-opacity + sky fog floor + additive fog-skip05a8a72fix(sky): retail-faithful sun-vector magnitude for SunColor / AmbientColor034a684fix(sky): partition sky pass on Properties bit 0x01, not bit 0x04375065bfix(meshing): Translucent flag overrides Additive blend per retail SetSurface646cccafeat(sky): load Setup-backed (0x020xxx) sky objects via SetupMesh.Flatten0c82d2cdocs(issues): #28 root-caused (PES particles), #29 filed Net effect: * Sun + ambient colors now use retail's |sunVec| magnitude formula from PrimD3DRender::UpdateLightsInternal at decomp 424118 — fixes blue-white sky tint at most keyframes. * Surface.Translucency is used DIRECTLY as opacity (not 1-x) per D3DPolyRender::SetSurface at decomp 425255 — fixes 3× too-bright cloud + correct rain alpha. * Sky fog re-enabled with SKY_FOG_FLOOR=0.2 mitigation — horizon haze visible without flat-fogging the dome at storm keyframes. * Additive surfaces skip fog per SetFFFogAlphaDisabled at decomp 425295 — sun stays bright at horizon dusk/dawn. * Pre/post-scene partition is bit 0x01 (post-scene placement) instead of bit 0x04 (weather gate), per GameSky::CreateDeletePhysicsObjects at decomp 269036. Fixes double-rendered foreground rain. * Translucent flag forces alpha-blend over Additive when ClipMap is set, matching retail's blend resolution at decomp 425246-425260. Cloud surface 0x08000023 now classified correctly. * Setup-backed sky objects (0x020xxxxx) now load via SetupMesh.Flatten instead of being silently dropped by EnsureMeshUploaded. Tests: 1227 pass. User-visible improvements: foreground rain matches retail's volumetric look, sky tint shifted from blue-white toward retail's warm-gray, additive sun stays bright through horizon haze. Outstanding: * Issue #28 — PES particle rendering ("aurora light play"). Now root-caused with implementation outline; defer to its own Phase. * Issue #29 — residual cloud-density gap; likely rolls into #28. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> # Conflicts: # src/AcDream.App/Rendering/GameWindow.cs
This commit is contained in:
commit
f7c9e88b6a
18 changed files with 1439 additions and 290 deletions
|
|
@ -200,6 +200,21 @@ public static class GfxObjMesh
|
|||
// docs/research/2026-04-23-sky-retail-verbatim.md §6).
|
||||
var translucency = TranslucencyKind.Opaque;
|
||||
var luminosity = 0f;
|
||||
// SurfTranslucency = the OPACITY multiplier the shader applies
|
||||
// to fragment alpha. 1.0 = fully opaque (default, non-Translucent
|
||||
// surfaces). For Translucent-flag surfaces, retail's
|
||||
// D3DPolyRender::SetSurface at 0x59c7a6 (decomp lines 425255-
|
||||
// 425260) computes curr_alpha = _ftol2(translucency × 255) and
|
||||
// feeds that as vertex.color.alpha — so the dat's Translucency
|
||||
// float is the OPACITY directly (NOT inverted). For rain
|
||||
// (translucency=0.5) opacity is 0.5; for cloud surface
|
||||
// 0x08000023 (translucency=0.25) opacity is 0.25 — that's why
|
||||
// retail's clouds are dim and acdream's were 3× too bright
|
||||
// before this fix (we used 1-translucency, inverting the
|
||||
// semantic). ACViewer's TextureCache.cs:142 and WorldBuilder's
|
||||
// ObjectMeshManager.cs:1115 also use 1-translucency and are
|
||||
// both wrong by the same misread.
|
||||
var surfTranslucency = 1.0f;
|
||||
if (dats is not null)
|
||||
{
|
||||
var surface = dats.Get<Surface>(surfaceId);
|
||||
|
|
@ -207,9 +222,33 @@ public static class GfxObjMesh
|
|||
{
|
||||
translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type);
|
||||
luminosity = surface.Luminosity;
|
||||
// Apply the dat's Translucency value as opacity ONLY
|
||||
// when the Translucent flag (0x10) is set on the
|
||||
// Surface. Without this gate, surfaces with
|
||||
// Translucency=0 (non-Translucent default) would
|
||||
// render fully transparent.
|
||||
if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0)
|
||||
surfTranslucency = surface.Translucency;
|
||||
}
|
||||
}
|
||||
|
||||
// Authored UV range determines the wrap-mode choice in the
|
||||
// sky pass. A mesh whose UVs are strictly in [0,1] (e.g. the
|
||||
// outer dome 0x010015EE) wants CLAMP_TO_EDGE to avoid
|
||||
// bilinear-filter bleed at the wall-seam edges; a mesh whose
|
||||
// UVs deliberately tile (e.g. 0x010015EF, ~0.4..4.6) wants
|
||||
// REPEAT so the texture tiles across the geometry. We make
|
||||
// the call data-driven here rather than guessing from
|
||||
// TexVelocity at draw time. See
|
||||
// docs/research/2026-04-26-sky-investigation-handoff.md (Bug B).
|
||||
bool needsUvRepeat = false;
|
||||
foreach (var v in kvp.Value.Vertices)
|
||||
{
|
||||
if (v.TexCoord.X < 0f || v.TexCoord.X > 1f
|
||||
|| v.TexCoord.Y < 0f || v.TexCoord.Y > 1f)
|
||||
{ needsUvRepeat = true; break; }
|
||||
}
|
||||
|
||||
result.Add(new GfxObjSubMesh(
|
||||
SurfaceId: surfaceId,
|
||||
Vertices: kvp.Value.Vertices.ToArray(),
|
||||
|
|
@ -217,6 +256,8 @@ public static class GfxObjMesh
|
|||
{
|
||||
Translucency = translucency,
|
||||
Luminosity = luminosity,
|
||||
NeedsUvRepeat = needsUvRepeat,
|
||||
SurfTranslucency = surfTranslucency,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -39,4 +39,41 @@ public sealed record GfxObjSubMesh(
|
|||
/// normal lighting path without change.
|
||||
/// </summary>
|
||||
public float Luminosity { get; init; } = 0f;
|
||||
|
||||
/// <summary>
|
||||
/// True when at least one vertex's UV component lies outside the
|
||||
/// <c>[0, 1]</c> range, meaning the mesh was authored to have its
|
||||
/// texture tile across the geometry (i.e. it expects
|
||||
/// <c>GL_REPEAT</c>/<c>D3DTADDRESS_WRAP</c>). The sky renderer reads
|
||||
/// this to decide between <c>GL_REPEAT</c> (this flag set, or any
|
||||
/// scrolling layer) and <c>GL_CLAMP_TO_EDGE</c> (all UVs strictly
|
||||
/// in <c>[0,1]</c>), which avoids wall-seam bleed on the dome
|
||||
/// (UVs in <c>[0,1]</c>) while still tiling the inner star/cloud
|
||||
/// layers (UVs in <c>[~0.4, ~4.6]</c>) correctly.
|
||||
/// Defaults to false so non-sky consumers get the previous behavior.
|
||||
/// </summary>
|
||||
public bool NeedsUvRepeat { get; init; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// <c>Surface.Translucency</c> float (0..1) treated as an OPACITY
|
||||
/// multiplier on fragment alpha. 1.0 = fully opaque (default for
|
||||
/// non-Translucent surfaces). Distinct from the
|
||||
/// <see cref="TranslucencyKind"/> classifier above, which buckets the
|
||||
/// flag bits. Retail's <c>D3DPolyRender::SetSurface</c> at
|
||||
/// <c>0x59c7a6</c> (decomp lines 425255-425260) reads
|
||||
/// <c>Surface.Translucency</c> when the <c>Translucent</c> (0x10) bit
|
||||
/// is set, computes <c>curr_alpha = _ftol2(translucency × 255)</c>,
|
||||
/// and writes that as vertex alpha — i.e. the dat's Translucency float
|
||||
/// is used DIRECTLY as opacity, NOT inverted. ACViewer
|
||||
/// (<c>TextureCache.cs:142</c>) and WorldBuilder
|
||||
/// (<c>ObjectMeshManager.cs:1115</c>) both use <c>1 - translucency</c>
|
||||
/// and are wrong by the same misread.
|
||||
/// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5;
|
||||
/// with the <c>(SrcAlpha, One)</c> additive blend the rain streaks
|
||||
/// contribute at half intensity. For cloud surface 0x08000023
|
||||
/// (translucency=0.25): opacity = 0.25 (matches retail's dim clouds).
|
||||
/// Defaults to 1.0 (fully opaque) so non-Translucent surfaces render
|
||||
/// at full opacity without change.
|
||||
/// </summary>
|
||||
public float SurfTranslucency { get; init; } = 1f;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,17 +40,38 @@ public enum TranslucencyKind
|
|||
|
||||
public static class TranslucencyKindExtensions
|
||||
{
|
||||
// Priority order (highest wins):
|
||||
// 1. Additive — SurfaceType.Additive (0x10000)
|
||||
// 2. InvAlpha — SurfaceType.InvAlpha (0x200)
|
||||
// 3. AlphaBlend — SurfaceType.Alpha (0x100) OR SurfaceType.Translucent (0x10)
|
||||
// 4. ClipMap — SurfaceType.Base1ClipMap (0x04)
|
||||
// 5. Opaque — everything else
|
||||
// Translucent override comes FIRST, then the existing priority chain:
|
||||
// 1. Translucent override — Translucent (0x10) AND (ClipMap OR opaque-base)
|
||||
// → AlphaBlend (matches retail's blend forcing).
|
||||
// 2. Additive — SurfaceType.Additive (0x10000)
|
||||
// 3. InvAlpha — SurfaceType.InvAlpha (0x200)
|
||||
// 4. AlphaBlend — SurfaceType.Alpha (0x100) OR SurfaceType.Translucent (0x10)
|
||||
// 5. ClipMap — SurfaceType.Base1ClipMap (0x04)
|
||||
// 6. Opaque — everything else
|
||||
//
|
||||
// Note: ACViewer groups Base1ClipMap with the alpha-draw bucket (AlphaSurfaceTypes),
|
||||
// but acdream keeps its existing alpha-discard approach for clip-map surfaces
|
||||
// (they render opaque with per-fragment discard) and introduces a separate
|
||||
// translucent pass only for the genuinely blended surface types.
|
||||
// The Translucent override matches retail's D3DPolyRender::SetSurface
|
||||
// at 0x0059c4d0 (decomp lines 425083-425260). Verbatim from the
|
||||
// Translucent branch at 425246:
|
||||
//
|
||||
// if ((curr_surface_type & 0x10) != 0) {
|
||||
// if (skipChk != 0 || ebx == 0 || arg3 == 1) {
|
||||
// edi_2 = BLEND_SRCALPHA; // src
|
||||
// ebp = BLEND_INVSRCALPHA; // dst ← alpha-blend
|
||||
// ebx = 1; arg1 = 1; arg3 = 0;
|
||||
// }
|
||||
// curr_alpha = _ftol2(translucency * 255);
|
||||
// }
|
||||
//
|
||||
// Where `arg3 = 1` after the ClipMap branch and `ebx == 0` happens
|
||||
// in Branch 2 when the surface would otherwise be opaque (no Additive,
|
||||
// Alpha, or InvAlpha bits). So Translucent + ClipMap (e.g. cloud
|
||||
// surface 0x08000023, Type=0x10114) renders ALPHA-BLEND in retail
|
||||
// even though the Additive flag is also set; previously acdream's
|
||||
// priority-Additive-first classification mis-routed it as additive.
|
||||
// Empirically: this is the surface for cloud GfxObj 0x01004C35 in
|
||||
// every Cloudy/Rainy DayGroup. Misclassifying it as additive made
|
||||
// acdream's clouds barely-visible "brightness adders" rather than
|
||||
// the dense alpha-blended sheets retail shows.
|
||||
|
||||
/// <summary>
|
||||
/// Maps a <see cref="SurfaceType"/> flags value to the correct
|
||||
|
|
@ -58,6 +79,19 @@ public static class TranslucencyKindExtensions
|
|||
/// </summary>
|
||||
public static TranslucencyKind FromSurfaceType(SurfaceType type)
|
||||
{
|
||||
// Step 1: Translucent override — matches retail's branch at
|
||||
// decomp line 425250 where (skipChk || ebx == 0 || arg3 == 1)
|
||||
// forces (SrcAlpha, InvSrcAlpha) regardless of Additive.
|
||||
bool isTranslucent = (type & SurfaceType.Translucent) != 0;
|
||||
bool isClipMap = (type & SurfaceType.Base1ClipMap) != 0;
|
||||
bool wouldBeOpaque =
|
||||
(type & (SurfaceType.Additive
|
||||
| SurfaceType.Alpha
|
||||
| SurfaceType.InvAlpha)) == 0;
|
||||
if (isTranslucent && (isClipMap || wouldBeOpaque))
|
||||
return TranslucencyKind.AlphaBlend;
|
||||
|
||||
// Step 2..6: existing priority order for non-overridden surfaces.
|
||||
if ((type & SurfaceType.Additive) != 0)
|
||||
return TranslucencyKind.Additive;
|
||||
|
||||
|
|
|
|||
|
|
@ -90,21 +90,30 @@ public static class DerethDateTime
|
|||
GloamingAndHalf,
|
||||
}
|
||||
|
||||
/// <summary>Derethian months (Snowreap..Frostfell, 12 total).</summary>
|
||||
/// <summary>
|
||||
/// Derethian months in chronological order. Year-0 begins at month 0
|
||||
/// (<see cref="Morningthaw"/>) and progresses through the 12-month
|
||||
/// cycle. Names + order match retail's calendar display
|
||||
/// (<c>GameTime::CalcDayBegin</c> + <c>GetDateTimeString</c> at
|
||||
/// <c>0x005a6530</c>) and ACE's <c>DerethDateTime.cs</c>. Verified
|
||||
/// against retail's <c>@timestamp</c> output in 2026-04-27 dual-
|
||||
/// client comparison: at day-of-year 83, retail shows
|
||||
/// "Seedsow 24" — that fixes month index 2 = Seedsow.
|
||||
/// </summary>
|
||||
public enum MonthName
|
||||
{
|
||||
Snowreap = 0,
|
||||
ColdMeet,
|
||||
Leafdawning,
|
||||
Seedsow,
|
||||
Rosetide,
|
||||
Morningthaw = 0,
|
||||
Solclaim,
|
||||
Seedsow,
|
||||
Leafdawning,
|
||||
Verdantine,
|
||||
Thistledown,
|
||||
Harvestgain,
|
||||
Leaftrue,
|
||||
Reaptide,
|
||||
Morningthaw,
|
||||
Leafcull,
|
||||
Frostfell,
|
||||
Snowreap,
|
||||
Coldeve,
|
||||
Wintersebb,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -127,12 +136,15 @@ public static class DerethDateTime
|
|||
/// for the boot window before the dat parses.
|
||||
///
|
||||
/// <para>
|
||||
/// Live Dereth dat value: <c>3600</c>. The +7/16 default is wrong
|
||||
/// by 266.25 ticks (~33 Derethian minutes) and was the source of
|
||||
/// the "acdream time is behind retail" + "wrong DayGroup picked"
|
||||
/// observations in the 2026-04-23 live verification session — see
|
||||
/// <c>docs/research/2026-04-23-daygroup-selection.md</c> §4 and
|
||||
/// the Phase 3f commit.
|
||||
/// Live Dereth dat value: <c>3600</c>. Retail's
|
||||
/// <c>GameTime::CalcDayBegin</c> at <c>0x005a6400</c> (decomp line
|
||||
/// 434549) computes <c>arg2 + zero_time_of_year</c> as the basis for
|
||||
/// year/day-of-year extraction, then derives <c>time_of_day_begin</c>
|
||||
/// such that <c>(arg2 - time_of_day_begin) / day_length</c> in
|
||||
/// <c>CalcTimeOfDay</c> gives <c>(arg2 + zero_time_of_year) mod day_length / day_length</c>.
|
||||
/// Net: the formula is ADD, not subtract — confirmed via the explicit
|
||||
/// add at line 434549. (A 2026-04-26 attempt to flip the sign over-
|
||||
/// corrected and broke DG selection; reverted in the same commit.)
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static double OriginOffsetTicks { get; private set; } = DayFractionOriginOffsetTicks;
|
||||
|
|
@ -186,7 +198,10 @@ public static class DerethDateTime
|
|||
|
||||
/// <summary>
|
||||
/// Derethian calendar breakdown: (year, month, day, hour).
|
||||
/// Year starts at PY 0. Day is 1-based within the month (1..30).
|
||||
/// <see cref="Year"/> is the absolute Portal Year (= relative-year +
|
||||
/// <see cref="ZeroYear"/>) so the value matches retail's
|
||||
/// <c>@timestamp</c> output ("Date: <Month> <Day>,
|
||||
/// <Year> P.Y."). Day is 1-based within the month (1..30).
|
||||
/// </summary>
|
||||
public readonly record struct Calendar(int Year, MonthName Month, int Day, HourName Hour);
|
||||
|
||||
|
|
@ -194,15 +209,19 @@ public static class DerethDateTime
|
|||
{
|
||||
if (ticks < 0) ticks = 0;
|
||||
double shifted = ticks + OriginOffsetTicks;
|
||||
int year = (int)(shifted / YearTicks);
|
||||
double tYear = shifted - year * YearTicks;
|
||||
int relativeYear = (int)(shifted / YearTicks);
|
||||
double tYear = shifted - relativeYear * YearTicks;
|
||||
int monthIdx = (int)(tYear / MonthTicks);
|
||||
if (monthIdx > 11) monthIdx = 11;
|
||||
double tMonth = tYear - monthIdx * MonthTicks;
|
||||
int day = (int)(tMonth / DayTicks) + 1;
|
||||
if (day > DaysInAMonth) day = DaysInAMonth;
|
||||
|
||||
return new Calendar(year, (MonthName)monthIdx, day, CurrentHour(ticks));
|
||||
// Absolute Portal Year for display: retail's @timestamp shows
|
||||
// PY-with-base (10 P.Y. == year 0 of the calendar epoch), so add
|
||||
// ZeroYear here. Matches AbsoluteYear() and the retail decomp at
|
||||
// FUN_005a7510:5300.
|
||||
return new Calendar(relativeYear + ZeroYear, (MonthName)monthIdx, day, CurrentHour(ticks));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,45 @@ public sealed class SkyObjectData
|
|||
public uint GfxObjId;
|
||||
public uint Properties;
|
||||
|
||||
/// <summary>
|
||||
/// True when this SkyObject is gated on the weather system (Properties
|
||||
/// bit <c>0x04</c>). Per the named retail decomp,
|
||||
/// <c>GameSky::CreateDeletePhysicsObjects</c> at <c>0x005073c0</c>
|
||||
/// passes <c>Properties & 4</c> as <c>arg5</c> of
|
||||
/// <c>GameSky::MakeObject</c> (<c>0x00506ee0</c>); the inner
|
||||
/// <c>(arg5 == 0 || LScape::weather_enabled != 0)</c> guard at decomp
|
||||
/// line 268630 means weather-flagged objects only get instantiated when
|
||||
/// the global weather flag is on. This bit does <b>not</b> control
|
||||
/// pre/post-scene placement — that's <see cref="IsPostScene"/>.
|
||||
/// acdream currently always renders weather-flagged objects (we don't
|
||||
/// honor a weather_enabled toggle yet); when we add one, this flag is
|
||||
/// the gate.
|
||||
/// </summary>
|
||||
public bool IsWeather => (Properties & 0x04u) != 0u;
|
||||
|
||||
/// <summary>
|
||||
/// True when this SkyObject renders <i>after</i> the world scene
|
||||
/// (Properties bit <c>0x01</c>) — i.e. as foreground over terrain and
|
||||
/// entities. Per the named retail decomp,
|
||||
/// <c>GameSky::CreateDeletePhysicsObjects</c> passes
|
||||
/// <c>Properties & 1</c> as <c>arg4</c> of
|
||||
/// <c>GameSky::MakeObject</c> (decomp line 269036); MakeObject at
|
||||
/// decomp 268656 routes <c>arg4 != 0</c> objects into
|
||||
/// <c>after_sky_cell</c> instead of <c>before_sky_cell</c>, and
|
||||
/// <c>GameSky::Draw(arg2=1)</c> at <c>0x00506ff0</c> draws
|
||||
/// <c>after_sky_cell</c> as a separate post-scene pass.
|
||||
/// <para>
|
||||
/// In Dereth's Rainy DayGroup this distinguishes the two rain
|
||||
/// cylinders: <c>0x01004C44</c> (Props=0x05) is foreground rain
|
||||
/// rendered after terrain; <c>0x01004C42</c> (Props=0x04 alone) is
|
||||
/// background rain rendered <i>with</i> the sky dome. Earlier
|
||||
/// versions of acdream incorrectly split on <see cref="IsWeather"/>
|
||||
/// (bit 0x04) so both rain meshes ended up in the post-scene pass,
|
||||
/// double-rendering rain in the foreground.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public bool IsPostScene => (Properties & 0x01u) != 0u;
|
||||
|
||||
/// <summary>Object is visible at day-fraction <paramref name="t"/>
|
||||
/// by retail's begin/end semantics (r12 §2). Three cases:
|
||||
/// <list type="bullet">
|
||||
|
|
@ -534,12 +573,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,
|
||||
|
|
|
|||
|
|
@ -34,24 +34,82 @@ 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 the shader feeds into N·L lighting.
|
||||
/// Retail-faithful magnitude formula:
|
||||
/// <code>SunColor = DirColor × |sunVec|</code>
|
||||
/// where <c>sunVec</c> is retail's heading+pitch+brightness vector
|
||||
/// (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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length();
|
||||
|
||||
/// <summary>
|
||||
/// Final ambient color the shader feeds into the per-vertex tint.
|
||||
/// Retail-faithful magnitude formula:
|
||||
/// <code>AmbientColor = AmbColor × (AmbBright + 0.2 × |sunVec|)</code>
|
||||
/// matching <c>SmartBox::SetWorldAmbientLight</c> as called at
|
||||
/// <c>0x0050560b</c> (decomp line 267117):
|
||||
/// <code>SetWorldAmbientLight(sqrt(|sunVec|²) × 0.2 + ambient_level, ambient_color)</code>
|
||||
/// Retail boosts the ambient brightness by 20% of the sun-vector
|
||||
/// magnitude — i.e. ambient feels warmer when the sun is up, cooler
|
||||
/// at night. acdream previously used <c>AmbBright</c> alone, which
|
||||
/// is roughly 44% too dim mid-day ⇒ contributed to the blue-white
|
||||
/// bias because the warm fill was missing.
|
||||
/// </summary>
|
||||
public Vector3 AmbientColor =>
|
||||
AmbColor * (AmbBright + 0.2f * SkyStateProvider.RetailSunVector(this).Length());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sky keyframe interpolator — given a day fraction in [0, 1), returns
|
||||
|
|
@ -111,12 +169,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 +190,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 +203,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 +216,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 +264,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);
|
||||
}
|
||||
|
||||
|
|
@ -222,22 +300,52 @@ public sealed class SkyStateProvider
|
|||
return aDeg + delta * u;
|
||||
}
|
||||
|
||||
/// <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):
|
||||
/// <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)
|
||||
/// </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"/>.
|
||||
/// </summary>
|
||||
public static Vector3 RetailSunVector(SkyKeyframe kf)
|
||||
{
|
||||
float h = kf.SunHeadingDeg * (MathF.PI / 180f);
|
||||
float p = kf.SunPitchDeg * (MathF.PI / 180f);
|
||||
float cosP = MathF.Cos(p);
|
||||
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)
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// TOWARDS the sun, derived from <see cref="RetailSunVector"/> and
|
||||
/// normalized. The shader sunDir uniform should use this directly
|
||||
/// (or -this if the lighting math wants the L-vector pointing AT the
|
||||
/// surface). The previous implementation used standard spherical
|
||||
/// coordinates (sin(H)cos(P), cos(H)cos(P), sin(P)) which didn't match
|
||||
/// retail's deliberate Y-decoupled-from-heading convention. Switching
|
||||
/// to the retail vector subtly tilts the lighting on objects but
|
||||
/// matches retail's screenshots when both clients view the same scene.
|
||||
/// </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));
|
||||
var v = RetailSunVector(kf);
|
||||
float len = v.Length();
|
||||
return len > 1e-6f ? v / len : Vector3.UnitZ;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue