fix(lighting): #143 — dynamic lights use retail's D3D 1/d attenuation (portal + viewer spread)

The portal swirl's magenta light (and the viewer fill) read as a tight,
concentrated pool vs retail's soft, room-wide tint. Cause: acdream applied its
STATIC dat-bake falloff (1/d^3 distance-cube + range x1.3) to ALL point lights,
including dynamic ones. Retail draws dynamic lights through the D3D hardware
path (config_hardware_light 0x0059ad30): a point light gets Attenuation1=1 =>
att = 1/d (inverse-linear), plain Lambert, range x1.5 (rangeAdjust 0x00820cc4).

Split the two paths by a per-light IsDynamic flag:
- LightSource.IsDynamic; packed into GlobalLight.coneAngleEtc.y (binding=4).
- LightInfoLoader.Load(isDynamic) => range x1.5 + flag (server-object/portal
  lights via the live spawn path); dat-static lights keep x1.3 (default).
- Viewer fill + weenie/portal lights = dynamic; dat torches = static.
- mesh_modern.vert pointContribution: dynamic branch = 1/d att, plain Lambert,
  hard cutoff, no per-light cap (D3D accumulates then saturates via the existing
  min(pointAcc,1)); static branch = the unchanged wrap/norm bake.

This is the portal half of #143 (the magenta light itself now registers + reaches
the walls via the prior weenie-light + landblock-key fix). Refines AP-35: point
lights now split static-bake (1/d^3) vs dynamic-hardware (1/d) by path.

Verified: portal light now range=9 (6x1.5), magenta spreads softly; shader
compiles clean; static torches unchanged (range 5.2/6.5/7.8). User-confirmed the
portal matches retail and the torch-lit interior did not over-brighten.
Core lighting 44/44, App 476 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 18:14:57 +02:00
parent 0d8b827721
commit 57c2ab735d
6 changed files with 41 additions and 17 deletions

View file

@ -3283,7 +3283,8 @@ public sealed class GameWindow : IDisposable
liteSetup,
ownerId: entity.Id,
entityPosition: entity.Position,
entityRotation: entity.Rotation);
entityRotation: entity.Rotation,
isDynamic: true); // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
foreach (var ls in loaded)
_lightingSink.RegisterOwnedLight(ls);
}

View file

@ -165,8 +165,29 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised)
float distsq = dot(toL, toL);
float d = sqrt(distsq);
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3
float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 (static) / × 1.5 (dynamic)
if (d >= range || range <= 1e-4) return vec3(0.0);
float intensity = L.colorAndIntensity.w;
vec3 baseCol = L.colorAndIntensity.xyz;
// #143: DYNAMIC lights (viewer fill, portal, server-object lights — flagged by
// coneAngleEtc.y==1 from GlobalLightPacker) use retail's D3D hardware attenuation
// (config_hardware_light 0x0059ad30): a POINT light is given Attenuation1=1 ⇒
// att = 1/d (inverse-LINEAR), plain Lambert N·L, hard range cutoff. That spreads
// softly across the room (the portal tint, the viewer fill) instead of the static
// bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light
// cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1).
if (L.coneAngleEtc.y > 0.5) {
vec3 Ldir = toL / max(d, 1e-4);
float ndl = max(0.0, dot(N, Ldir));
if (ndl <= 0.0) return vec3(0.0);
if (kind == 2) { // dynamic spot: hard cos-cone gate
if (dot(-Ldir, L.dirAndRange.xyz) <= cos(L.coneAngleEtc.x * 0.5)) return vec3(0.0);
}
return (intensity * ndl / max(d, 1e-3)) * baseCol; // att = 1/d
}
// ── STATIC dat-baked lights: retail's per-vertex bake (calc_point_light 0x0059c8b0) ──
// A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the
// half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT
// mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes
@ -177,9 +198,8 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
if (angular <= 0.0) return vec3(0.0);
// NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo;
// <1 m → just d (dodge the near singularity). "Punchy near, soft far."
float norm = (distsq > 1.0) ? (distsq * d) : d;
float intensity = L.colorAndIntensity.w;
float scale = (1.0 - d / range) * intensity * (angular / norm);
float norm = (distsq > 1.0) ? (distsq * d) : d;
float scale = (1.0 - d / range) * intensity * (angular / norm);
if (kind == 2) {
// Spotlight: hard-edged cos-cone gate layered on the point ramp.
vec3 Ldir = toL / max(d, 1e-4);
@ -189,7 +209,6 @@ vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) {
}
// Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled):
// a single light can't push a channel past its colour. Summed lit clamped in frag.
vec3 baseCol = L.colorAndIntensity.xyz;
return min(scale * baseCol, baseCol);
}