From 57c2ab735d371a6c27c22625746ef68a2e694593 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 18:14:57 +0200 Subject: [PATCH] =?UTF-8?q?fix(lighting):=20#143=20=E2=80=94=20dynamic=20l?= =?UTF-8?q?ights=20use=20retail's=20D3D=201/d=20attenuation=20(portal=20+?= =?UTF-8?q?=20viewer=20spread)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 3 +- .../Rendering/Shaders/mesh_modern.vert | 29 +++++++++++++++---- .../Lighting/GlobalLightPacker.cs | 3 +- src/AcDream.Core/Lighting/LightInfoLoader.cs | 20 ++++++------- src/AcDream.Core/Lighting/LightManager.cs | 1 + src/AcDream.Core/Lighting/LightSource.cs | 2 ++ 6 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e427a88b..b16ef017 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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); } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index f031a149..dd8f7f4a 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -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); } diff --git a/src/AcDream.Core/Lighting/GlobalLightPacker.cs b/src/AcDream.Core/Lighting/GlobalLightPacker.cs index 9de709a5..3d718dee 100644 --- a/src/AcDream.Core/Lighting/GlobalLightPacker.cs +++ b/src/AcDream.Core/Lighting/GlobalLightPacker.cs @@ -47,8 +47,9 @@ public static class GlobalLightPacker buffer[o + 9] = L.ColorLinear.Y; buffer[o + 10] = L.ColorLinear.Z; buffer[o + 11] = L.Intensity; - // coneAngleEtc (x cone radians; yzw reserved) + // coneAngleEtc (x cone radians; y = #143 dynamic flag; zw reserved) buffer[o + 12] = L.ConeAngle; + buffer[o + 13] = L.IsDynamic ? 1f : 0f; // shader: 1/d D3D attenuation vs static 1/d³ } return n; } diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index 671da599..f3b02348 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -36,7 +36,8 @@ public static class LightInfoLoader Setup setup, uint ownerId, Vector3 entityPosition, - Quaternion entityRotation) + Quaternion entityRotation, + bool isDynamic = false) { var results = new List(); if (setup?.Lights is null || setup.Lights.Count == 0) return results; @@ -79,18 +80,17 @@ public static class LightInfoLoader (info.Color?.Green ?? 255) / 255f, (info.Color?.Blue ?? 255) / 255f), Intensity = info.Intensity, - // falloff_eff for the per-vertex point-light burn-in (calc_point_light - // 0x0059c8b0) is Falloff * static_light_factor, where static_light_factor - // is the fixed global 1.3 (0x00820e24). That is the path that lights - // STATIC walls — what the dungeon/house "spotlight" report (#133 A7) is - // about — so we match it, not the D3D-dynamic config_hardware_light - // rangeAdjust (1.5, a different path for moving objects). The shader ramp - // (1 - dist/Range) fades to exactly 0 at this Range, eliminating the hard - // disc edge that read as a spotlight. - Range = info.Falloff * 1.3f, + // Range factor by light path (#143). STATIC dat-baked lights use + // static_light_factor 1.3 (calc_point_light 0x0059c8b0 bake — the path + // that lights dungeon/house WALLS, #133 A7) and the shader's 1/d³ ramp. + // DYNAMIC lights (server-object/portal lights registered from the live + // spawn path with isDynamic:true) use config_hardware_light's rangeAdjust + // 1.5 (0x00820cc4) and the shader's D3D 1/d attenuation — softer, broader. + Range = info.Falloff * (isDynamic ? 1.5f : 1.3f), ConeAngle = info.ConeAngle, OwnerId = ownerId, IsLit = true, + IsDynamic = isDynamic, }; results.Add(light); } diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 57d9789b..e3f7605a 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -260,6 +260,7 @@ public sealed class LightManager Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5 OwnerId = ViewerLightOwnerId, IsLit = true, + IsDynamic = true, // #143: D3D 1/d attenuation (soft fill, not 1/d³) }; _all.Add(_viewerLight); } diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs index 7bfb7d45..c07e79b9 100644 --- a/src/AcDream.Core/Lighting/LightSource.cs +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -47,6 +47,8 @@ public sealed class LightSource public float ConeAngle = 0f; // radians, Spot only public uint OwnerId; // attached entity id; 0 = world-global public bool IsLit = true; // SetLightHook latch + public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5); + // false = static dat-baked bake (1/d³, range×1.3) // Cached each frame by LightManager. public float DistSq;