From 3b93f91ebe0b86ff7caec153a24c8612a182a6f0 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 14 Jun 2026 14:27:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(A7):=20LightBake=20Core=20=E2=80=94=20veri?= =?UTF-8?q?fied=20per-vertex=20static-light=20burn-in=20(foundation,=20not?= =?UTF-8?q?=20wired)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The faithful fix for the spotty dungeon/house/outdoor lighting is retail's per-vertex static-light bake (D3DPolyRender::SetStaticLightingVertexColors 0x0059cfe0), NOT a per-pixel ramp. This lands the GL-free Core: LightBake.PointContribution / ComputeVertexColor port calc_point_light (0x0059c8b0) VERBATIM — verified against a clean Ghidra decompile (the BN pseudo-C is x87-mangled): half-Lambert wrap with LIGHT_POINT_RANGE=0.75 (0x007e5430), the distsq>1 norm branch, the per-channel min-to-color clamp, and the final [0,1] clamp. static_light_factor=1.3 (0x00820e24) is already folded into LightSource.Range by LightInfoLoader. 7 conformance tests (hand-derived golden values) green. NOT wired yet — the integration (a per-vertex colour attribute on the cell mesh + the bake driver keyed on envCellId + the shader consumption) is the remaining A7 work; see ISSUES.md A7. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.Core/Lighting/LightBake.cs | 101 ++++++++++++++++ .../Lighting/LightBakeTests.cs | 109 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/AcDream.Core/Lighting/LightBake.cs create mode 100644 tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs diff --git a/src/AcDream.Core/Lighting/LightBake.cs b/src/AcDream.Core/Lighting/LightBake.cs new file mode 100644 index 00000000..1ab52714 --- /dev/null +++ b/src/AcDream.Core/Lighting/LightBake.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.Core.Lighting; + +/// +/// Retail per-vertex static-light burn-in. Ported verbatim from +/// calc_point_light (acclient 0x0059c8b0), the function retail's +/// D3DPolyRender::SetStaticLightingVertexColors (0x0059cfe0) runs over +/// EVERY vertex of an EnvCell mesh × EVERY reaching static light, baking the +/// result into the vertex diffuse colour ONCE (then the rasteriser Gouraud- +/// interpolates it across each triangle and the texture stage modulates it). +/// +/// +/// This is the faithful answer to the dungeon "spotlight" look (#133 A7): our +/// old per-pixel nearest-8 path lit only the 8 torches nearest the CAMERA and +/// re-ranked them every frame (the sliding crescent). The retail bake sums ALL +/// reaching lights into the vertex once, keyed on light position not camera — +/// uniform, stable, and never blown out (each light is clamped to its own +/// colour, then the vertex sum is clamped to [0,1]). +/// +/// +/// Constants (decomp-cited, not guessed): +/// +/// static_light_factor = 1.3 (0x00820e24) — folded into +/// by LightInfoLoader, so +/// falloff_eff == light.Range here. +/// LIGHT_POINT_RANGE = 0.75 (0x007e5430) — the half-Lambert wrap +/// uses 2·LPR = 1.5 as the divisor and (2·LPR − 1) = 0.5 as the +/// distance bias, so even surfaces angled away from a torch receive some light. +/// +/// +public static class LightBake +{ + // calc_point_light literals. + private const float TwoLpr = 1.5f; // LIGHT_POINT_RANGE + LIGHT_POINT_RANGE + private const float WrapBias = 0.5f; // (2 · LIGHT_POINT_RANGE) − 1.0 + + /// + /// Accumulate one static light's contribution into a per-vertex RGB sum, + /// exactly as calc_point_light does. Returns the contribution to ADD + /// (already per-channel clamped to the light's own colour); the caller sums + /// over all reaching lights and clamps the total to [0,1]. + /// + public static Vector3 PointContribution( + Vector3 vtxWorldPos, Vector3 vtxWorldNormal, LightSource light) + { + // D = light − vertex (FROM vertex TO light), used un-normalised. + float dx = light.WorldPosition.X - vtxWorldPos.X; + float dy = light.WorldPosition.Y - vtxWorldPos.Y; + float dz = light.WorldPosition.Z - vtxWorldPos.Z; + + float distsq = dx * dx + dy * dy + dz * dz; + float dist = MathF.Sqrt(distsq); + float falloffEff = light.Range; // = Falloff × static_light_factor(1.3) + if (dist >= falloffEff || falloffEff <= 1e-4f) + return Vector3.Zero; + + // Half-Lambert wrap: (1/1.5)·(N·D + 0.5·dist), N un-normalised vertex normal. + float wrap = (1f / TwoLpr) * + (vtxWorldNormal.X * dx + vtxWorldNormal.Y * dy + vtxWorldNormal.Z * dz + + WrapBias * dist); + if (wrap <= 0f) + return Vector3.Zero; + + // norm branch — ported EXACTLY (changes the near-vs-far falloff shape). + float norm = distsq > 1f ? distsq * dist : dist; + float scale = (1f - dist / falloffEff) * light.Intensity * (wrap / norm); + + // Per channel: contribution clamped to the light's own colour (a single + // light can never push a channel past its colour — the no-blowout ceiling). + return new Vector3( + MathF.Min(scale * light.ColorLinear.X, light.ColorLinear.X), + MathF.Min(scale * light.ColorLinear.Y, light.ColorLinear.Y), + MathF.Min(scale * light.ColorLinear.Z, light.ColorLinear.Z)); + } + + /// + /// Bake the full per-vertex colour by summing every reaching lit point/spot + /// light, then clamping to [0,1] (the SetStaticLightingVertexColors + /// final clamp). Directional lights are skipped — they are handled by the + /// sun path, not the static burn-in. + /// + public static Vector3 ComputeVertexColor( + Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList reaching) + { + float r = 0f, g = 0f, b = 0f; + for (int i = 0; i < reaching.Count; i++) + { + var light = reaching[i]; + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + var c = PointContribution(vtxWorldPos, vtxWorldNormal, light); + r += c.X; g += c.Y; b += c.Z; + } + return new Vector3( + Math.Clamp(r, 0f, 1f), + Math.Clamp(g, 0f, 1f), + Math.Clamp(b, 0f, 1f)); + } +} diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs new file mode 100644 index 00000000..be082b37 --- /dev/null +++ b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Numerics; +using AcDream.Core.Lighting; +using Xunit; + +namespace AcDream.Core.Tests.Lighting; + +/// +/// Conformance tests for the per-vertex static-light burn-in +/// (), ported from retail calc_point_light +/// (0x0059c8b0). Golden values are hand-derived from the decompiled equation: +/// wrap = (1/1.5)·(N·D + 0.5·dist); norm = distsq>1 ? distsq·dist : dist; +/// scale = (1 − dist/Range)·intensity·(wrap/norm); contrib = min(scale·color, color). +/// +public sealed class LightBakeTests +{ + private static LightSource Torch(Vector3 pos, float intensity = 100f, float range = 10f) + => new LightSource + { + Kind = LightKind.Point, + WorldPosition = pos, + ColorLinear = Vector3.One, + Intensity = intensity, + Range = range, + IsLit = true, + }; + + [Fact] + public void NearTorch_FacingIt_SaturatesToColor() + { + // Vertex at origin facing up (+Z); torch 2 m above. + // dist=2, distsq=4, wrap=(1/1.5)(2+1)=2, norm=4·2=8, + // scale=(1-0.2)·100·(2/8)=20 → min(20·1,1)=1 per channel. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 2))); + Assert.Equal(1f, c.X, 4); + Assert.Equal(1f, c.Y, 4); + Assert.Equal(1f, c.Z, 4); + } + + [Fact] + public void FarTorch_FallsOffSmoothly() + { + // Torch 8 m above (still within Range 10). scale=(1-0.8)·100·(8/512)=0.3125. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 8))); + Assert.Equal(0.3125f, c.X, 4); + Assert.Equal(0.3125f, c.Y, 4); + Assert.Equal(0.3125f, c.Z, 4); + } + + [Fact] + public void OutOfRange_ContributesNothing() + { + // Torch 11 m above, Range 10 → dist >= falloff_eff, skipped. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 11))); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void FacingAway_BeyondWrap_ContributesNothing() + { + // Normal points away (−Z) from a torch above: N·D=−2, wrap=(1/1.5)(−2+1)<0. + var c = LightBake.PointContribution( + Vector3.Zero, new Vector3(0, 0, -1), Torch(new Vector3(0, 0, 2))); + Assert.Equal(Vector3.Zero, c); + } + + [Fact] + public void HalfLambertWrap_LightsSurfaceAngledPast90Degrees() + { + // Normal at ~100° from the light direction still gets light (Lambert would not). + // Light straight above (+Z 2 m); normal tilted to (sin100°, 0, cos100°). + double t = 100.0 * Math.PI / 180.0; + var n = new Vector3((float)Math.Sin(t), 0, (float)Math.Cos(t)); // cos100° < 0 + var c = LightBake.PointContribution(Vector3.Zero, n, Torch(new Vector3(0, 0, 2))); + Assert.True(c.X > 0f, "half-Lambert wrap should light a surface angled past 90°"); + } + + [Fact] + public void ComputeVertexColor_SumsLightsAndClampsToOne() + { + // Two saturating torches → sum clamps to 1, never overflows. + var lights = new[] + { + Torch(new Vector3(0, 0, 2)), + Torch(new Vector3(0, 0, 2)), + }; + var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); + Assert.Equal(1f, c.X, 4); + Assert.Equal(1f, c.Y, 4); + Assert.Equal(1f, c.Z, 4); + } + + [Fact] + public void ComputeVertexColor_SkipsDirectionalAndUnlit() + { + var lights = new[] + { + new LightSource { Kind = LightKind.Directional, WorldPosition = new Vector3(0,0,2), + ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = true }, + new LightSource { Kind = LightKind.Point, WorldPosition = new Vector3(0,0,2), + ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = false }, + }; + var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); + Assert.Equal(Vector3.Zero, c); + } +}