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:
Erik 2026-06-20 12:01:20 +02:00
commit 31d7ffd253
27 changed files with 2327 additions and 103 deletions

View file

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Lighting;
/// <summary>
/// Packs a point-light snapshot into the flat float layout the bindless mesh
/// shader reads at SSBO binding=4 (<c>mesh_modern.vert</c> <c>GlobalLight gLights[]</c>):
/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity,
/// coneAngleEtc. Pure (no GL), so both <c>WbDrawDispatcher</c> and
/// <c>EnvCellRenderer</c> share ONE layout and cannot drift.
/// </summary>
public static class GlobalLightPacker
{
public const int FloatsPerLight = 16;
/// <summary>
/// Fill <paramref name="buffer"/> (grown + zero-cleared as needed) with the
/// packed snapshot; returns the light count <c>n</c>. The buffer always has at
/// least <see cref="FloatsPerLight"/> floats (so a zero-light frame still
/// uploads a non-empty SSBO). Callers upload <c>max(n,1) * FloatsPerLight</c> floats.
/// </summary>
public static int Pack(IReadOnlyList<LightSource>? snapshot, ref float[] buffer)
{
int n = snapshot?.Count ?? 0;
int floatsNeeded = Math.Max(n, 1) * FloatsPerLight;
if (buffer.Length < floatsNeeded)
buffer = new float[floatsNeeded + FloatsPerLight * 16];
Array.Clear(buffer, 0, floatsNeeded);
for (int i = 0; i < n; i++)
{
var L = snapshot![i];
int o = i * FloatsPerLight;
// posAndKind (xyz world pos, w kind)
buffer[o + 0] = L.WorldPosition.X;
buffer[o + 1] = L.WorldPosition.Y;
buffer[o + 2] = L.WorldPosition.Z;
buffer[o + 3] = (int)L.Kind;
// dirAndRange (xyz forward, w range)
buffer[o + 4] = L.WorldForward.X;
buffer[o + 5] = L.WorldForward.Y;
buffer[o + 6] = L.WorldForward.Z;
buffer[o + 7] = L.Range; // w = Range = Falloff × static_light_factor (1.3), pre-multiplied by LightInfoLoader — NOT the raw dat Falloff
// colorAndIntensity (xyz linear colour, w intensity)
buffer[o + 8] = L.ColorLinear.X;
buffer[o + 9] = L.ColorLinear.Y;
buffer[o + 10] = L.ColorLinear.Z;
buffer[o + 11] = L.Intensity;
// coneAngleEtc (x cone radians; yzw reserved)
buffer[o + 12] = L.ConeAngle;
}
return n;
}
}

View file

@ -157,4 +157,125 @@ public sealed class LightManager
_activeCount = baseSlot + filled;
}
// ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ──
//
// The single global nearest-8-to-VIEWER set above (Tick) is camera-relative:
// a wall's brightness changes as the camera moves because the wall's torches
// swap in/out of that global top-8. Retail instead picks up-to-8 lights PER
// OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480),
// so a torch always lights the wall it sits on, camera-independent. The two
// members below feed the per-instance light path in WbDrawDispatcher; Tick
// remains the source of the legacy single-UBO path + the sun slot.
/// <summary>Max point/spot lights any one object can be lit by — retail's
/// D3D fixed-function 8-light cap (<c>minimize_object_lighting</c>). The sun
/// is global, not part of an object's per-object set, so all 8 are point/spot.</summary>
public const int MaxLightsPerObject = 8;
/// <summary>Hard cap on the per-frame global point-light snapshot the shader
/// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128
/// is generous. If exceeded, the nearest-to-camera are kept (cold path).</summary>
public const int MaxGlobalLights = 128;
private readonly List<LightSource> _pointSnapshot = new();
/// <summary>
/// Per-frame snapshot of lit point/spot lights, stable-indexed for the global
/// shader light buffer and for per-object selection: the index of a light here
/// IS the index the per-instance light-set SSBO references. Built by
/// <see cref="BuildPointLightSnapshot"/>.
/// </summary>
public IReadOnlyList<LightSource> PointSnapshot => _pointSnapshot;
/// <summary>
/// Rebuild <see cref="PointSnapshot"/> from the registered lit point/spot
/// lights. The sun and unlit lights are excluded (the sun is global ambient-
/// path; unlit torches contribute nothing). When more than
/// <see cref="MaxGlobalLights"/> qualify, keeps the nearest the camera so the
/// most relevant lights survive the cap. Call once per frame before
/// per-object selection.
/// </summary>
public void BuildPointLightSnapshot(Vector3 cameraWorldPos)
{
_pointSnapshot.Clear();
foreach (var light in _all)
{
if (!light.IsLit || light.Kind == LightKind.Directional) continue;
light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared();
_pointSnapshot.Add(light);
}
if (_pointSnapshot.Count > MaxGlobalLights)
{
_pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq));
_pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights);
}
}
/// <summary>
/// Select up to <see cref="MaxLightsPerObject"/> point/spot lights from
/// <paramref name="snapshot"/> that reach the object sphere
/// (<paramref name="center"/>, <paramref name="radius"/>), nearest-first.
/// Faithful to retail's <c>minimize_object_lighting</c> (0x0054d480): a light
/// is a candidate iff its falloff sphere overlaps the object sphere —
/// <c>(light.pos center)² &lt; (light.Range + radius)²</c> — and when more
/// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the
/// farthest fall off). <paramref name="light.Range"/> already folds
/// <c>static_light_factor</c> (1.3), matching the per-vertex cutoff so a
/// selected light always actually contributes in the shader.
/// <para>
/// Writes indices INTO <paramref name="snapshot"/> to
/// <paramref name="outIndices"/> (ascending by distance) and returns the count.
/// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a
/// static object's set is stable and may be computed once. Unit-testable
/// without GL.
/// </para>
/// </summary>
public static int SelectForObject(
IReadOnlyList<LightSource> snapshot,
Vector3 center,
float radius,
Span<int> outIndices)
{
int cap = Math.Min(outIndices.Length, MaxLightsPerObject);
if (cap <= 0) return 0;
Span<float> keptDistSq = stackalloc float[MaxLightsPerObject];
int count = 0;
for (int li = 0; li < snapshot.Count; li++)
{
var light = snapshot[li];
float reach = light.Range + radius;
float dsq = (light.WorldPosition - center).LengthSquared();
if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object
if (count < cap)
{
int j = count;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
count++;
}
else if (dsq < keptDistSq[cap - 1])
{
int j = cap - 1;
while (j > 0 && keptDistSq[j - 1] > dsq)
{
keptDistSq[j] = keptDistSq[j - 1];
outIndices[j] = outIndices[j - 1];
j--;
}
keptDistSq[j] = dsq;
outIndices[j] = li;
}
}
return count;
}
}

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>