acdream/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs
Erik 3b93f91ebe 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>
2026-06-14 14:27:45 +02:00

109 lines
4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&gt;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);
}
}