feat(A7): LightBake Core — verified per-vertex static-light burn-in (foundation, not wired)
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) <noreply@anthropic.com>
This commit is contained in:
parent
3e641339e9
commit
3b93f91ebe
2 changed files with 210 additions and 0 deletions
109
tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs
Normal file
109
tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Lighting;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Lighting;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for the per-vertex static-light burn-in
|
||||
/// (<see cref="LightBake"/>), ported from retail <c>calc_point_light</c>
|
||||
/// (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).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue