acdream/src/AcDream.Core/Lighting/LightBake.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

101 lines
4.6 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.Collections.Generic;
using System.Numerics;
namespace AcDream.Core.Lighting;
/// <summary>
/// Retail per-vertex static-light burn-in. Ported verbatim from
/// <c>calc_point_light</c> (acclient 0x0059c8b0), the function retail's
/// <c>D3DPolyRender::SetStaticLightingVertexColors</c> (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).
///
/// <para>
/// 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]).
/// </para>
///
/// <para>Constants (decomp-cited, not guessed):</para>
/// <list type="bullet">
/// <item><c>static_light_factor</c> = 1.3 (0x00820e24) — folded into
/// <see cref="LightSource.Range"/> by <c>LightInfoLoader</c>, so
/// <c>falloff_eff == light.Range</c> here.</item>
/// <item><c>LIGHT_POINT_RANGE</c> = 0.75 (0x007e5430) — the half-Lambert wrap
/// uses <c>2·LPR = 1.5</c> as the divisor and <c>(2·LPR 1) = 0.5</c> as the
/// distance bias, so even surfaces angled away from a torch receive some light.</item>
/// </list>
/// </summary>
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
/// <summary>
/// Accumulate one static light's contribution into a per-vertex RGB sum,
/// exactly as <c>calc_point_light</c> 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].
/// </summary>
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));
}
/// <summary>
/// Bake the full per-vertex colour by summing every reaching lit point/spot
/// light, then clamping to [0,1] (the <c>SetStaticLightingVertexColors</c>
/// final clamp). Directional lights are skipped — they are handled by the
/// sun path, not the static burn-in.
/// </summary>
public static Vector3 ComputeVertexColor(
Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList<LightSource> 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));
}
}