merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch
Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D, GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack (D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md. Conflict resolved: both lineages used #140 for different issues. Kept main's #140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to #141 (note added; this branch's commits/spec still reference #140 — immutable). The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D). Build + full suite green on the merged tree (2,713 passed / 4 skipped). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
31d7ffd253
27 changed files with 2327 additions and 103 deletions
|
|
@ -0,0 +1,42 @@
|
|||
using AcDream.App.Rendering.Wb;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering.Wb;
|
||||
|
||||
/// <summary>
|
||||
/// A7 Fix D round 2 — pins retail's <c>useSunlight</c> gate for per-object torch
|
||||
/// lighting (<c>WbDrawDispatcher.IndoorObjectReceivesTorches</c>). Retail enables
|
||||
/// the static wall-torches on an object ONLY in the indoor stage
|
||||
/// (<c>DrawMeshInternal</c> 0x0059f398: <c>if (useSunlight == 0) minimize_object_lighting()</c>),
|
||||
/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor
|
||||
/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only
|
||||
/// EnvCell-parented (indoor, low word >= 0x0100) objects receive torches.
|
||||
/// </summary>
|
||||
public sealed class WbDrawDispatcherTorchGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildingShell_NullParent_IsOutdoor_NoTorches()
|
||||
{
|
||||
// Building exterior shells are top-level landblock stabs with no
|
||||
// ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent).
|
||||
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xA9B4_0001u)] // outdoor land sub-cell
|
||||
[InlineData(0xA9B4_0020u)] // outdoor land sub-cell
|
||||
[InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40)
|
||||
public void OutdoorLandCell_NoTorches(uint parentCellId)
|
||||
{
|
||||
Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0xA9B4_0100u)] // first EnvCell
|
||||
[InlineData(0xA9B4_0164u)] // interior EnvCell
|
||||
[InlineData(0x0007_0143u)] // dungeon EnvCell
|
||||
public void IndoorEnvCell_GetsTorches(uint parentCellId)
|
||||
{
|
||||
Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.Options;
|
||||
using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo;
|
||||
using DatSetup = DatReaderWriter.DBObjs.Setup;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace AcDream.Core.Tests.Conformance;
|
||||
|
||||
/// <summary>
|
||||
/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without
|
||||
/// guessing or a live launch: dump the RAW dat <c>LightInfo.Falloff</c> for every
|
||||
/// static light in the Holtburg landblocks, via the EXACT production load path
|
||||
/// (<see cref="LightInfoLoader.Load"/>). The dat is the SAME file retail reads, so
|
||||
/// these falloffs ARE what retail reads (modulo any load-time transform, settled
|
||||
/// separately in the decomp). Output-only — no assertions; read the log.
|
||||
/// </summary>
|
||||
public sealed class HoltburgTorchFalloffProbeTests
|
||||
{
|
||||
private readonly ITestOutputHelper _out;
|
||||
public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output;
|
||||
|
||||
[Fact]
|
||||
public void Dump_Holtburg_StaticLight_Falloffs()
|
||||
{
|
||||
var datDir = ConformanceDats.ResolveDatDir();
|
||||
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||
|
||||
// The meeting hall sits in the Holtburg town landblocks. Sweep a small
|
||||
// neighbourhood so we catch every entrance torch the streaming window
|
||||
// would load around the player at the hall.
|
||||
uint[] landblocks =
|
||||
{
|
||||
0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u,
|
||||
};
|
||||
|
||||
// Tally every distinct raw Falloff seen (the headline number).
|
||||
var falloffTally = new SortedDictionary<float, int>();
|
||||
int totalLights = 0;
|
||||
|
||||
foreach (uint lb in landblocks)
|
||||
{
|
||||
uint infoId = (lb << 16) | 0xFFFEu;
|
||||
var info = dats.Get<DatLandBlockInfo>(infoId);
|
||||
if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; }
|
||||
|
||||
int buildings = info.Buildings?.Count ?? 0;
|
||||
int objects = info.Objects?.Count ?? 0;
|
||||
_out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ===");
|
||||
|
||||
// Record building-shell origins so we can rank torches by proximity.
|
||||
var shells = new List<(uint model, Vector3 pos)>();
|
||||
if (info.Buildings is not null)
|
||||
{
|
||||
foreach (var b in info.Buildings)
|
||||
{
|
||||
var o = b.Frame.Origin;
|
||||
shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z)));
|
||||
_out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}");
|
||||
}
|
||||
}
|
||||
|
||||
if (info.Objects is null) continue;
|
||||
foreach (var stab in info.Objects)
|
||||
{
|
||||
// Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary —
|
||||
// identical gate to GameWindow.cs:6399.
|
||||
if ((stab.Id & 0xFF000000u) != 0x02000000u) continue;
|
||||
var setup = dats.Get<DatSetup>(stab.Id);
|
||||
if (setup?.Lights is null || setup.Lights.Count == 0) continue;
|
||||
|
||||
var loaded = LightInfoLoader.Load(
|
||||
setup,
|
||||
ownerId: 0,
|
||||
entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z),
|
||||
entityRotation: new Quaternion(
|
||||
stab.Frame.Orientation.X, stab.Frame.Orientation.Y,
|
||||
stab.Frame.Orientation.Z, stab.Frame.Orientation.W));
|
||||
|
||||
foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l)))
|
||||
{
|
||||
float rawFalloff = kvp.Value.Falloff;
|
||||
totalLights++;
|
||||
falloffTally.TryGetValue(rawFalloff, out int c);
|
||||
falloffTally[rawFalloff] = c + 1;
|
||||
|
||||
// Nearest building shell, for "is this an entrance torch on the hall?".
|
||||
float nearest = float.MaxValue;
|
||||
uint nearestModel = 0;
|
||||
foreach (var (model, spos) in shells)
|
||||
{
|
||||
float dd = Vector3.Distance(ls.WorldPosition, spos);
|
||||
if (dd < nearest) { nearest = dd; nearestModel = model; }
|
||||
}
|
||||
|
||||
_out.WriteLine(
|
||||
$" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " +
|
||||
$"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " +
|
||||
$"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " +
|
||||
$"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " +
|
||||
$"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ===");
|
||||
foreach (var kv in falloffTally)
|
||||
_out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}");
|
||||
}
|
||||
}
|
||||
45
tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs
Normal file
45
tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
public class GlobalLightPackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout()
|
||||
{
|
||||
var light = new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(10f, 20f, 30f),
|
||||
WorldForward = new Vector3(0f, 0f, 1f),
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
ConeAngle = 0f,
|
||||
};
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
|
||||
int count = GlobalLightPacker.Pack(new[] { light }, ref buffer);
|
||||
|
||||
Assert.Equal(1, count);
|
||||
Assert.True(buffer.Length >= 16);
|
||||
Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]);
|
||||
Assert.Equal((float)(int)LightKind.Point, buffer[3]);
|
||||
Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]);
|
||||
Assert.Equal(5.2f, buffer[7]);
|
||||
Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]);
|
||||
Assert.Equal(100f, buffer[11]);
|
||||
Assert.Equal(0f, buffer[12]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot()
|
||||
{
|
||||
float[] buffer = System.Array.Empty<float>();
|
||||
int count = GlobalLightPacker.Pack(null, ref buffer);
|
||||
Assert.Equal(0, count);
|
||||
Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp),
|
||||
/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that
|
||||
/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1)
|
||||
/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md.
|
||||
/// </summary>
|
||||
public class LightBakeConformanceTests
|
||||
{
|
||||
private static LightSource OrangeTorch(Vector3 pos) => new()
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = pos,
|
||||
ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange
|
||||
Intensity = 100f,
|
||||
Range = 4f * 1.3f, // falloff 4 × static_light_factor
|
||||
IsLit = true,
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData(1f)]
|
||||
[InlineData(2f)]
|
||||
[InlineData(3f)]
|
||||
[InlineData(4f)]
|
||||
[InlineData(5f)]
|
||||
public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist)
|
||||
{
|
||||
var vtx = Vector3.Zero;
|
||||
var normal = Vector3.UnitX;
|
||||
var torch = OrangeTorch(new Vector3(dist, 0f, 0f));
|
||||
|
||||
var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch });
|
||||
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
if (c.X > 0f)
|
||||
{
|
||||
Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}");
|
||||
Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BeyondRange_ContributesNothing()
|
||||
{
|
||||
var torch = OrangeTorch(new Vector3(100f, 0f, 0f));
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch });
|
||||
Assert.Equal(Vector3.Zero, c);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManyOverlappingIntenseTorches_StillClampToOne()
|
||||
{
|
||||
var lights = new List<LightSource>();
|
||||
for (int i = 0; i < 8; i++)
|
||||
lights.Add(new LightSource
|
||||
{
|
||||
Kind = LightKind.Point,
|
||||
WorldPosition = new Vector3(1.5f, 0.1f * i, 0f),
|
||||
ColorLinear = new Vector3(0.98f, 0.95f, 0.9f),
|
||||
Intensity = 100f,
|
||||
Range = 5.2f,
|
||||
IsLit = true,
|
||||
});
|
||||
|
||||
var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights);
|
||||
Assert.InRange(c.X, 0f, 1f);
|
||||
Assert.InRange(c.Y, 0f, 1f);
|
||||
Assert.InRange(c.Z, 0f, 1f);
|
||||
}
|
||||
}
|
||||
|
|
@ -144,4 +144,116 @@ public sealed class LightManagerTests
|
|||
mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4
|
||||
Assert.Equal(16f, light.DistSq, 2);
|
||||
}
|
||||
|
||||
// ── Fix B: per-object selection (minimize_object_lighting) ────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in
|
||||
mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out
|
||||
mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out
|
||||
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Single(mgr.PointSnapshot);
|
||||
Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPointLightSnapshot_IndexStable_InBudget()
|
||||
{
|
||||
var mgr = new LightManager();
|
||||
// Registration order preserved when under MaxGlobalLights (no sort).
|
||||
mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far
|
||||
mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near
|
||||
|
||||
mgr.BuildPointLightSnapshot(Vector3.Zero);
|
||||
|
||||
Assert.Equal(2, mgr.PointSnapshot.Count);
|
||||
Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered
|
||||
Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_EmptySnapshot_ReturnsZero()
|
||||
{
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(System.Array.Empty<LightSource>(), Vector3.Zero, 1f, idx);
|
||||
Assert.Equal(0, n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_InRange_Selected()
|
||||
{
|
||||
var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
|
||||
Assert.Equal(1, n);
|
||||
Assert.Equal(0, idx[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_OutOfRange_Excluded()
|
||||
{
|
||||
// dist 10, range 5, radius 0 → 10 >= 5 → excluded.
|
||||
var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) };
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
|
||||
Assert.Equal(0, n);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_ObjectRadiusExtendsReach()
|
||||
{
|
||||
// dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere
|
||||
// overlaps (7 < 5+3). The whole object catches the light — retail uses the
|
||||
// object's bounding sphere, not its centre point.
|
||||
var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) };
|
||||
Span<int> idx = stackalloc int[8];
|
||||
|
||||
Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx));
|
||||
Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_MoreThan8_KeepsNearest8()
|
||||
{
|
||||
// 10 candidate lights all in range; expect the 8 nearest the object centre,
|
||||
// ascending by distance, with the two farthest dropped.
|
||||
var snapshot = new LightSource[10];
|
||||
for (int i = 0; i < 10; i++)
|
||||
snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range
|
||||
|
||||
Span<int> idx = stackalloc int[8];
|
||||
int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx);
|
||||
|
||||
Assert.Equal(8, n);
|
||||
// Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest
|
||||
// (indices 8,9 / dist 9,10) are evicted.
|
||||
for (int k = 0; k < 8; k++)
|
||||
Assert.Equal(k, idx[k]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre()
|
||||
{
|
||||
// Same snapshot, same object centre → identical selection regardless of
|
||||
// where any "camera" is (the method takes no camera). This is the property
|
||||
// that kills the "lights up as I approach" popping.
|
||||
var snapshot = new[]
|
||||
{
|
||||
MakePoint(new Vector3(2, 0, 0), range: 10f),
|
||||
MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0
|
||||
};
|
||||
Span<int> a = stackalloc int[8];
|
||||
Span<int> b = stackalloc int[8];
|
||||
int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a);
|
||||
int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b);
|
||||
|
||||
Assert.Equal(1, na);
|
||||
Assert.Equal(na, nb);
|
||||
Assert.Equal(a[0], b[0]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue