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