From aa94cedc38435a7618def818579ee777d29bbae1 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:27:27 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(render):=20A7=20point-light=20shape=20?= =?UTF-8?q?=E2=80=94=20per-vertex=20Gouraud=20+=20faithful=20calc=5Fpoint?= =?UTF-8?q?=5Flight=20(wrap=20+=20norm)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The torch/point-light look was wrong two ways, both now fixed against the named retail decomp (calc_point_light 0x0059c8b0) via our verified LightBake.PointContribution port: 1. Per-PIXEL → per-VERTEX. accumulateLights moved from mesh_modern.frag to mesh_modern.vert so point lights Gouraud-interpolate across each triangle the way retail's fixed-function T&L does. The per-pixel eval made a tight, hard-edged "spotlight" pool on flat walls; per-vertex is a soft, broad gradient. frag now just consumes the interpolated vLit (+ fog + flash). 2. Simplified ramp → faithful calc_point_light shape. The live point/spot branch was max(0,N·L) × linear(1−d/range) × cap — missing two terms our LightBake.cs port already has: • half-Lambert WRAP (1/1.5)·(N·D + 0.5·d), D un-normalised — a face angled away from a torch still catches light (retail's soft terminator) instead of snapping to black. • distance-cube NORM branch norm = distsq>1 ? distsq·d : d — inverse- square-ish soft far halo + punchy near field, vs the flat linear ramp. Per-channel no-blowout cap (min(scale·color, color)) retained. The per-channel cap was also added to the legacy mesh.frag for consistency. A read-only retail-vs-acdream lighting audit (11-agent workflow) confirmed these two as the cause of the "better but a bit off" look and cleared the ambient/sun/terrain/color-space chain as already faithful. Remaining confirmed divergences (per-object light selection; dungeon static vertex bake) are filed as the next fixes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/Shaders/mesh.frag | 5 +- .../Rendering/Shaders/mesh_modern.frag | 48 ++--------- .../Rendering/Shaders/mesh_modern.vert | 81 +++++++++++++++++++ 3 files changed, 93 insertions(+), 41 deletions(-) diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index 45fe4e7f..f2e879ae 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -84,7 +84,10 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) { float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); atten *= (cos_l > cos_edge) ? 1.0 : 0.0; } - lit += Lcol * ndl * atten; + // Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single + // point/spot light can't push a channel past its own colour, regardless of + // intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag. + lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz); } } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 040e15b2..4f344369 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -4,6 +4,7 @@ in vec3 vNormal; in vec2 vTexCoord; in vec3 vWorldPos; +in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert in flat uvec2 vTextureHandle; in flat uint vTextureLayer; @@ -31,44 +32,11 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -vec3 accumulateLights(vec3 N, vec3 worldPos) { - vec3 lit = uCellAmbient.xyz; - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - int kind = int(uLights[i].posAndKind.w); - vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; - if (kind == 0) { - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += Lcol * ndl; - } else { - vec3 toL = uLights[i].posAndKind.xyz - worldPos; - float d = length(toL); - float range = uLights[i].dirAndRange.w; - if (d < range && range > 1e-3) { - vec3 Ldir = toL / max(d, 1e-4); - float ndl = max(0.0, dot(N, Ldir)); - // Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0, - // line 0x0059c9a2): contribution scales by (1 - dist/falloff_eff), a - // LINEAR fade to exactly 0 at the edge. That is what makes a torch a - // smooth glow that blends into the ambient instead of a flat disc with - // a hard edge — the dungeon/house/outdoor "spotlight" look (#133 A7). - // falloff_eff = Falloff * static_light_factor (1.3, 0x00820e24) is folded - // into the shader Range (dirAndRange.w) by LightInfoLoader, so the ramp - // denominator is just Range and fades to 0 exactly at the cutoff. - float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0); - if (kind == 2) { - float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); - float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); - atten *= (cos_l > cos_edge) ? 1.0 : 0.0; - } - lit += Lcol * ndl * atten; - } - } - } - return lit; -} +// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match +// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight" +// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/ +// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed +// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload. vec3 applyFog(vec3 lit, vec3 worldPos) { int mode = int(uFogParams.w); @@ -114,8 +82,8 @@ void main() { if (color.a < 0.05) discard; } - vec3 N = normalize(vNormal); - vec3 lit = accumulateLights(N, vWorldPos); + // Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights). + vec3 lit = vLit; // Lightning flash — additive scene bump (matches mesh_instanced.frag). lit += uFogParams.z * vec3(0.6, 0.6, 0.75); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index ce4378ac..fa150cbc 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -96,9 +96,89 @@ uniform mat4 uViewProjection; // uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. uniform int uDrawIDOffset; +// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO +// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO +// above). IDENTICAL std140 layout to mesh_modern.frag. +// +// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so +// torch/point lights Gouraud-interpolate across each triangle the way retail's +// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for +// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls; +// per-vertex spreads it into a soft, broad gradient with no hard edge. +struct Light { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std140, binding = 1) uniform SceneLighting { + Light uLights[8]; + vec4 uCellAmbient; + vec4 uFogParams; + vec4 uFogColor; + vec4 uCameraAndTime; +}; + +vec3 accumulateLights(vec3 N, vec3 worldPos) { + vec3 lit = uCellAmbient.xyz; + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + int kind = int(uLights[i].posAndKind.w); + vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; + if (kind == 0) { + // Directional (sun): forward points INTO the scene; N·(-forward) = light-facing. + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += Lcol * ndl; + } else { + // Point / spot — FAITHFUL port of calc_point_light (0x0059c8b0) via our + // verified LightBake.PointContribution (LightBake.cs:46-77). D = light − + // vertex, used UN-normalised (length = dist); N is the unit vertex normal. + // (A7 2026-06-15 #2: the prior model was a simplification — plain + // max(0,N·L) × linear(1−d/range) — which gave a harsher terminator and a + // flatter falloff than retail. The two terms below are the fix.) + vec3 toL = uLights[i].posAndKind.xyz - worldPos; // D (un-normalised) + float distsq = dot(toL, toL); + float d = sqrt(distsq); + float range = uLights[i].dirAndRange.w; // falloff_eff = Falloff × 1.3 + if (d < range && range > 1e-4) { + // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). D is un-normalised so + // N·D = d·cosθ; the +0.5·d bias lets a face angled AWAY from the torch + // still catch light — retail's soft terminator. wrap≤0 = fully shadowed + // (retail early-out at 0x0059c8b0). TwoLpr=1.5, WrapBias=0.5. + float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); + if (wrap > 0.0) { + // NORM branch (the distance-cube term): beyond 1 m, divide by + // distsq·d ≈ inverse-square (soft far halo); within 1 m, divide by + // d only, to dodge a near singularity. This is the "punchy near, + // soft far" shape the flat linear ramp was flattening. + float norm = (distsq > 1.0) ? (distsq * d) : d; + float intensity = uLights[i].colorAndIntensity.w; + float scale = (1.0 - d / range) * intensity * (wrap / norm); + if (kind == 2) { + // Spotlight: hard-edged cos-cone gate layered on the point ramp. + vec3 Ldir = toL / max(d, 1e-4); + float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); + if (cos_l <= cos_edge) scale = 0.0; + } + // 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 + // (dat torch intensity ~100 would saturate). Summed lit clamped in frag. + vec3 baseCol = uLights[i].colorAndIntensity.xyz; + lit += min(scale * baseCol, baseCol); + } + } + } + } + return lit; +} + out vec3 vNormal; out vec2 vTexCoord; out vec3 vWorldPos; +out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights) out flat uvec2 vTextureHandle; out flat uint vTextureLayer; @@ -123,6 +203,7 @@ void main() { vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); + vLit = accumulateLights(vNormal, vWorldPos); // A7: per-vertex Gouraud lighting vTexCoord = aTexCoord; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; From 4345e77d62d6385e9264326344cecad0dfd2c626 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 22:47:40 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(render):=20A7=20Fix=20B=20=E2=80=94=20p?= =?UTF-8?q?er-OBJECT=20point-light=20selection=20(minimize=5Fobject=5Fligh?= =?UTF-8?q?ting)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outdoor objects brightened as the camera approached: lighting selected the nearest 8 lights to the VIEWER and fed that one global set to everything (LightManager.Tick), so a building's wall torches only lit it once the camera got close enough for them to win the global top-8. Probe confirmed the scale of the problem: a single Holtburg view registers 129 point lights — the global cap of 8 was hopeless. Retail selects up to 8 lights PER OBJECT by the object's own position (minimize_object_lighting 0x0054d480), so a torch always lights the wall it sits on, camera-independent. Ported faithfully: - LightManager.SelectForObject (pure, TDD, 8 new tests): candidacy (light.pos − center)² < (Range + radius)², nearest-8 among those. Plus BuildPointLightSnapshot for the per-frame stable-indexed light list. - mesh_modern.vert: two SSBOs — binding=4 GLOBAL point-light array (the snapshot), binding=5 per-instance light SET (8 int indices into it, -1 = unused), parallel to the binding=0 instance buffer (mirrors the U.3 clip-slot mechanism). accumulateLights keeps ambient + sun from the SceneLighting UBO (cleared as faithful by the lighting audit) and loops THIS instance's point lights. pointContribution factored out (same calc_point_light wrap+norm shape). - WbDrawDispatcher: per-entity light set computed ONCE at the isNewEntity site (constant across the entity's parts), by the entity's AABB sphere; threaded into grp.LightSets parallel to grp.Matrices; global + per-instance buffers uploaded in Phase 5. Camera-independent ⇒ stable for static buildings. - GameWindow: BuildPointLightSnapshot + dispatcher.SetSceneLights each frame. Tests: 17/17 LightManager + 36/36 dispatcher clip-slot/clip-frame green (parallel-array lockstep preserved). Visually gated: the meeting hall now holds steady as the camera approaches (was the popping symptom). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 10 ++ .../Rendering/Shaders/mesh_modern.vert | 131 +++++++++------ .../Rendering/Wb/WbDrawDispatcher.cs | 149 +++++++++++++++++- src/AcDream.Core/Lighting/LightManager.cs | 121 ++++++++++++++ .../Lighting/LightManagerTests.cs | 112 +++++++++++++ 5 files changed, 473 insertions(+), 50 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8f27733a..3735979e 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7766,6 +7766,16 @@ public sealed class GameWindow : IDisposable // frame — terrain, static mesh, instanced mesh, sky. UpdateSunFromSky(kf, playerInsideCell); Lighting.Tick(camPos); + + // Fix B (A7 #3): build this frame's point-light snapshot and hand it to + // the entity dispatcher for per-OBJECT light selection + // (minimize_object_lighting). Replaces the single global nearest-8-to- + // camera UBO set for point/spot lights so a wall's torches stay tied to + // the wall as the camera moves. The SUN + ambient still flow through the + // SceneLighting UBO built below (binding=1) — terrain/sky read those. + Lighting.BuildPointLightSnapshot(camPos); + _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); + var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index fa150cbc..2efd4a96 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -69,6 +69,33 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; +// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ===== +// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position +// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it +// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA +// UBO set (LightManager.Tick) made a wall brighten as the camera approached +// (its torches swapping into the global top-8). Two SSBOs replace that for +// point/spot lights (the SUN + ambient still come from the SceneLighting UBO): +// +// binding=4 — GLOBAL point/spot light array, uploaded once per frame from +// LightManager.PointSnapshot. The index of a light here is stable for the frame. +// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per +// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0 +// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills +// it once per entity (the set is constant across the entity's parts/tuples). +struct GlobalLight { + vec4 posAndKind; + vec4 dirAndRange; + vec4 colorAndIntensity; + vec4 coneAngleEtc; +}; +layout(std430, binding = 4) readonly buffer GlobalLightBuf { + GlobalLight gLights[]; +}; +layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { + int instanceLightIdx[]; // 8 per instance; -1 = unused +}; + // Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal // alongside gl_Position. The array is sized 8 to match the CellClip plane budget // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables @@ -119,58 +146,64 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -vec3 accumulateLights(vec3 N, vec3 worldPos) { +// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light — +// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D = +// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal. +// Returns the RGB to ADD, already per-channel capped to the light's own colour. +vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { + int kind = int(L.posAndKind.w); + 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 + if (d >= range || range <= 1e-4) return vec3(0.0); + // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). N·D = d·cosθ (D un-normalised); the + // +0.5·d bias lets a face angled AWAY from the torch still catch light (retail's + // soft terminator). wrap≤0 = fully shadowed. TwoLpr=1.5, WrapBias=0.5. + float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); + if (wrap <= 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 * (wrap / norm); + if (kind == 2) { + // Spotlight: hard-edged cos-cone gate layered on the point ramp. + vec3 Ldir = toL / max(d, 1e-4); + float cos_edge = cos(L.coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, L.dirAndRange.xyz); + if (cos_l <= cos_edge) scale = 0.0; + } + // 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); +} + +vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { vec3 lit = uCellAmbient.xyz; + + // SUN / directional — from the SceneLighting UBO (global; the audit cleared + // the ambient + sun chain as already faithful). Any point/spot entries still + // present in the UBO from LightManager.Tick are IGNORED here — point lights + // now come per-object from the SSBO below, so there's no double-count. int activeLights = int(uCellAmbient.w); for (int i = 0; i < 8; ++i) { if (i >= activeLights) break; - int kind = int(uLights[i].posAndKind.w); - vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; - if (kind == 0) { - // Directional (sun): forward points INTO the scene; N·(-forward) = light-facing. - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += Lcol * ndl; - } else { - // Point / spot — FAITHFUL port of calc_point_light (0x0059c8b0) via our - // verified LightBake.PointContribution (LightBake.cs:46-77). D = light − - // vertex, used UN-normalised (length = dist); N is the unit vertex normal. - // (A7 2026-06-15 #2: the prior model was a simplification — plain - // max(0,N·L) × linear(1−d/range) — which gave a harsher terminator and a - // flatter falloff than retail. The two terms below are the fix.) - vec3 toL = uLights[i].posAndKind.xyz - worldPos; // D (un-normalised) - float distsq = dot(toL, toL); - float d = sqrt(distsq); - float range = uLights[i].dirAndRange.w; // falloff_eff = Falloff × 1.3 - if (d < range && range > 1e-4) { - // Half-Lambert WRAP: (1/1.5)·(N·D + 0.5·d). D is un-normalised so - // N·D = d·cosθ; the +0.5·d bias lets a face angled AWAY from the torch - // still catch light — retail's soft terminator. wrap≤0 = fully shadowed - // (retail early-out at 0x0059c8b0). TwoLpr=1.5, WrapBias=0.5. - float wrap = (1.0 / 1.5) * (dot(N, toL) + 0.5 * d); - if (wrap > 0.0) { - // NORM branch (the distance-cube term): beyond 1 m, divide by - // distsq·d ≈ inverse-square (soft far halo); within 1 m, divide by - // d only, to dodge a near singularity. This is the "punchy near, - // soft far" shape the flat linear ramp was flattening. - float norm = (distsq > 1.0) ? (distsq * d) : d; - float intensity = uLights[i].colorAndIntensity.w; - float scale = (1.0 - d / range) * intensity * (wrap / norm); - if (kind == 2) { - // Spotlight: hard-edged cos-cone gate layered on the point ramp. - vec3 Ldir = toL / max(d, 1e-4); - float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); - float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); - if (cos_l <= cos_edge) scale = 0.0; - } - // 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 - // (dat torch intensity ~100 would saturate). Summed lit clamped in frag. - vec3 baseCol = uLights[i].colorAndIntensity.xyz; - lit += min(scale * baseCol, baseCol); - } - } - } + if (int(uLights[i].posAndKind.w) != 0) continue; // directional only + vec3 Ldir = -uLights[i].dirAndRange.xyz; // forward points INTO the scene + float ndl = max(0.0, dot(N, Ldir)); + lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; + } + + // POINT / SPOT — THIS object's selected set (minimize_object_lighting): 8 int + // slots per instance into the global light buffer, -1 = unused. Camera- + // independent, so a wall's torches light it the same regardless of viewer pos. + int base = instanceIndex * 8; + for (int k = 0; k < 8; ++k) { + int gi = instanceLightIdx[base + k]; + if (gi < 0) continue; + lit += pointContribution(N, worldPos, gLights[gi]); } return lit; } @@ -203,7 +236,7 @@ void main() { vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); - vLit = accumulateLights(vNormal, vWorldPos); // A7: per-vertex Gouraud lighting + vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights) vTexCoord = aTexCoord; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index e266be8c..6fbc3cd6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; +using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.Rendering; using AcDream.Core.Terrain; @@ -132,6 +133,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint _clipSlotSsbo; private uint[] _clipSlotData = new uint[256]; + // Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two + // SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot + // lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4) + // holds the per-frame point-light snapshot (LightManager.PointSnapshot); + // _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per + // instance INTO it (-1 = unused), laid out parallel to _instanceSsbo. + private uint _globalLightsSsbo; + private uint _instLightSetSsbo; + private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject]; + private float[] _globalLightData = new float[16 * 16]; // 16 floats (4 vec4) per GlobalLight + // This frame's point-light snapshot, handed in by GameWindow before Draw via + // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). + private IReadOnlyList? _pointSnapshot; + // This entity's selected point/spot light set — computed ONCE per entity at + // the isNewEntity site (constant across the entity's parts/tuples), exactly + // like _currentEntitySlot. -1 = unused slot. + private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject]; + // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the // GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0 // (not yet wired), we bind our OWN fallback no-clip region buffer below so the @@ -329,8 +348,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _batchSsbo = _gl.GenBuffer(); _indirectBuffer = _gl.GenBuffer(); _clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3 + _globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4 + _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 } + /// + /// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot + /// (). Call once per frame BEFORE + /// . The dispatcher uploads it to binding=4 and selects each + /// object's up-to-8 lights from it () + /// by the object's bounding sphere — camera-independent. Pass null/empty to + /// disable per-object point lights (only ambient + sun render). + /// + public void SetSceneLights(IReadOnlyList? pointSnapshot) + => _pointSnapshot = pointSnapshot; + /// /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO /// (binding=2) that created. The @@ -888,7 +920,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable camPos = invView.Translation; // ── Phase 1: clear groups, walk entities, build groups ────────────── - foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); } + foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); } var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -1053,6 +1085,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_currentEntityCulled) probeCulledEntities++; + // Fix B: select this entity's up-to-8 point/spot lights ONCE (the set + // is constant across the entity's parts/tuples), by the entity's + // bounding sphere — camera-INDEPENDENT (minimize_object_lighting). + ComputeEntityLightSet(entity); + // #119 decisive probe: one-shot dump (+ change re-emission) for // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue // so a routed-out entity still reports its state. @@ -1350,6 +1387,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_clipSlotData.Length < totalInstances) _clipSlotData = new uint[totalInstances + 256]; + // Fix B: per-instance light-set buffer, MaxLightsPerObject ints per + // instance, laid out in the SAME group order / cursor as _instanceData + // so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks + // Instances[instanceIndex] (binding=0). + if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject) + _lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject]; + _opaqueDraws.Clear(); _translucentDraws.Clear(); @@ -1375,6 +1419,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // Slots[] is parallel to Matrices[] within the group; write the // slot at the same cursor so binding=3 stays aligned with binding=0. _clipSlotData[cursor] = grp.Slots[i]; + // Fix B: LightSets[] holds 8 ints per instance, parallel to + // Matrices[]; copy this instance's block to the same cursor so + // binding=5 stays aligned with binding=0. + int lsDst = cursor * LightManager.MaxLightsPerObject; + int lsSrc = i * LightManager.MaxLightsPerObject; + for (int k = 0; k < LightManager.MaxLightsPerObject; k++) + _lightSetData[lsDst + k] = grp.LightSets[lsSrc + k]; cursor++; } @@ -1460,6 +1511,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable fixed (uint* sp = _clipSlotData) UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint)); + // Fix B: global point-light buffer (binding=4) + per-instance light-set + // buffer (binding=5). The global buffer is this frame's PointSnapshot; the + // per-instance buffer holds 8 int indices into it per instance, laid out + // parallel to _instanceData in Phase 3. Both bound with ≥1 element so the + // shader never reads an unbound SSBO on a no-lights frame. + UploadGlobalLights(); + fixed (int* lp = _lightSetData) + UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int)); + fixed (DrawElementsIndirectCommand* cp = _indirectCommands) { _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); @@ -1743,6 +1803,50 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); } + /// + /// Fix B: pack into the binding=4 global light + /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, + /// matching mesh_modern.vert's GlobalLight). Always uploads ≥1 element + /// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is + /// a zeroed dummy that no instance set references (all sets are -1). + /// + private unsafe void UploadGlobalLights() + { + var snap = _pointSnapshot; + int n = snap?.Count ?? 0; + int count = n > 0 ? n : 1; // never zero-size + int floatsNeeded = count * 16; + if (_globalLightData.Length < floatsNeeded) + _globalLightData = new float[floatsNeeded + 16 * 16]; + Array.Clear(_globalLightData, 0, floatsNeeded); + + for (int i = 0; i < n; i++) + { + var L = snap![i]; + int o = i * 16; + // posAndKind (xyz world pos, w kind) + _globalLightData[o + 0] = L.WorldPosition.X; + _globalLightData[o + 1] = L.WorldPosition.Y; + _globalLightData[o + 2] = L.WorldPosition.Z; + _globalLightData[o + 3] = (int)L.Kind; + // dirAndRange (xyz forward, w range = Falloff×1.3) + _globalLightData[o + 4] = L.WorldForward.X; + _globalLightData[o + 5] = L.WorldForward.Y; + _globalLightData[o + 6] = L.WorldForward.Z; + _globalLightData[o + 7] = L.Range; + // colorAndIntensity (xyz linear colour, w intensity) + _globalLightData[o + 8] = L.ColorLinear.X; + _globalLightData[o + 9] = L.ColorLinear.Y; + _globalLightData[o + 10] = L.ColorLinear.Z; + _globalLightData[o + 11] = L.Intensity; + // coneAngleEtc (x cone radians; yzw reserved) + _globalLightData[o + 12] = L.ConeAngle; + } + + fixed (float* gp = _globalLightData) + UploadSsbo(_globalLightsSsbo, 4, gp, count * 16 * sizeof(float)); + } + /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); @@ -1936,6 +2040,38 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } grp.Matrices.Add(model); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices + AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices + } + + /// + /// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result + /// reused by every part/instance of it), by the entity's world bounding + /// sphere. Camera-independent (), so + /// a static building's torches stay constant as the viewer moves. Fills + /// ; unused slots are -1. On the no-lights + /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. + /// + private void ComputeEntityLightSet(WorldEntity entity) + { + Array.Fill(_currentEntityLightSet, -1); + var snap = _pointSnapshot; + if (snap is null || snap.Count == 0) return; + + if (entity.AabbDirty) entity.RefreshAabb(); + Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; + float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; + LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet); + } + + /// + /// Fix B: append the current entity's 8-slot light set to a group's + /// , parallel to its Matrices (one + /// 8-int block per instance), mirroring grp.Slots.Add. + /// + private void AppendCurrentLightSet(InstanceGroup grp) + { + for (int k = 0; k < LightManager.MaxLightsPerObject; k++) + grp.LightSets.Add(_currentEntityLightSet[k]); } private void ClassifyBatches( @@ -1993,6 +2129,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } grp.Matrices.Add(model); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices + AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices collector?.Add(new CachedBatch(key, texHandle, restPose)); } } @@ -2072,6 +2209,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.DeleteBuffer(_indirectBuffer); if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3 if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3 + if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4 + if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5 if (_gpuQueriesInitialized) { for (int i = 0; i < GpuQueryRingDepth; i++) @@ -2257,5 +2396,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // _clipSlotData at the same cursor it writes Matrices[i] into _instanceData, // so the binding=3 instanceClipSlot[] tracks the binding=0 instance. public readonly List Slots = new(); + + // Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per + // instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected + // light index block for the instance whose matrix is Matrices[i]). At + // layout time the dispatcher copies each block into _lightSetData at the + // same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0 + // instance. -1 = unused slot. + public readonly List LightSets = new(); } } diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 24769c6e..95ea1edf 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -157,4 +157,125 @@ public sealed class LightManager _activeCount = baseSlot + filled; } + + // ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ── + // + // The single global nearest-8-to-VIEWER set above (Tick) is camera-relative: + // a wall's brightness changes as the camera moves because the wall's torches + // swap in/out of that global top-8. Retail instead picks up-to-8 lights PER + // OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480), + // so a torch always lights the wall it sits on, camera-independent. The two + // members below feed the per-instance light path in WbDrawDispatcher; Tick + // remains the source of the legacy single-UBO path + the sun slot. + + /// Max point/spot lights any one object can be lit by — retail's + /// D3D fixed-function 8-light cap (minimize_object_lighting). The sun + /// is global, not part of an object's per-object set, so all 8 are point/spot. + public const int MaxLightsPerObject = 8; + + /// Hard cap on the per-frame global point-light snapshot the shader + /// indexes. AC scenes rarely exceed a few dozen lit point lights in view; 128 + /// is generous. If exceeded, the nearest-to-camera are kept (cold path). + public const int MaxGlobalLights = 128; + + private readonly List _pointSnapshot = new(); + + /// + /// Per-frame snapshot of lit point/spot lights, stable-indexed for the global + /// shader light buffer and for per-object selection: the index of a light here + /// IS the index the per-instance light-set SSBO references. Built by + /// . + /// + public IReadOnlyList PointSnapshot => _pointSnapshot; + + /// + /// Rebuild from the registered lit point/spot + /// lights. The sun and unlit lights are excluded (the sun is global ambient- + /// path; unlit torches contribute nothing). When more than + /// qualify, keeps the nearest the camera so the + /// most relevant lights survive the cap. Call once per frame before + /// per-object selection. + /// + public void BuildPointLightSnapshot(Vector3 cameraWorldPos) + { + _pointSnapshot.Clear(); + foreach (var light in _all) + { + if (!light.IsLit || light.Kind == LightKind.Directional) continue; + light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared(); + _pointSnapshot.Add(light); + } + if (_pointSnapshot.Count > MaxGlobalLights) + { + _pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq)); + _pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights); + } + } + + /// + /// Select up to point/spot lights from + /// that reach the object sphere + /// (, ), nearest-first. + /// Faithful to retail's minimize_object_lighting (0x0054d480): a light + /// is a candidate iff its falloff sphere overlaps the object sphere — + /// (light.pos − center)² < (light.Range + radius)² — and when more + /// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the + /// farthest fall off). already folds + /// static_light_factor (1.3), matching the per-vertex cutoff so a + /// selected light always actually contributes in the shader. + /// + /// Writes indices INTO to + /// (ascending by distance) and returns the count. + /// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a + /// static object's set is stable and may be computed once. Unit-testable + /// without GL. + /// + /// + public static int SelectForObject( + IReadOnlyList snapshot, + Vector3 center, + float radius, + Span outIndices) + { + int cap = Math.Min(outIndices.Length, MaxLightsPerObject); + if (cap <= 0) return 0; + + Span keptDistSq = stackalloc float[MaxLightsPerObject]; + int count = 0; + + for (int li = 0; li < snapshot.Count; li++) + { + var light = snapshot[li]; + float reach = light.Range + radius; + float dsq = (light.WorldPosition - center).LengthSquared(); + if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object + + if (count < cap) + { + int j = count; + while (j > 0 && keptDistSq[j - 1] > dsq) + { + keptDistSq[j] = keptDistSq[j - 1]; + outIndices[j] = outIndices[j - 1]; + j--; + } + keptDistSq[j] = dsq; + outIndices[j] = li; + count++; + } + else if (dsq < keptDistSq[cap - 1]) + { + int j = cap - 1; + while (j > 0 && keptDistSq[j - 1] > dsq) + { + keptDistSq[j] = keptDistSq[j - 1]; + outIndices[j] = outIndices[j - 1]; + j--; + } + keptDistSq[j] = dsq; + outIndices[j] = li; + } + } + return count; + } } diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 1bb225a2..264c498c 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -144,4 +144,116 @@ public sealed class LightManagerTests mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 Assert.Equal(16f, light.DistSq, 2); } + + // ── Fix B: per-object selection (minimize_object_lighting) ──────────────── + + [Fact] + public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit() + { + var mgr = new LightManager(); + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in + mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out + mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Single(mgr.PointSnapshot); + Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); + } + + [Fact] + public void BuildPointLightSnapshot_IndexStable_InBudget() + { + var mgr = new LightManager(); + // Registration order preserved when under MaxGlobalLights (no sort). + mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far + mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near + + mgr.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(2, mgr.PointSnapshot.Count); + Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered + Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); + } + + [Fact] + public void SelectForObject_EmptySnapshot_ReturnsZero() + { + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(System.Array.Empty(), Vector3.Zero, 1f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_InRange_Selected() + { + var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5 + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(1, n); + Assert.Equal(0, idx[0]); + } + + [Fact] + public void SelectForObject_OutOfRange_Excluded() + { + // dist 10, range 5, radius 0 → 10 >= 5 → excluded. + var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + Assert.Equal(0, n); + } + + [Fact] + public void SelectForObject_ObjectRadiusExtendsReach() + { + // dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere + // overlaps (7 < 5+3). The whole object catches the light — retail uses the + // object's bounding sphere, not its centre point. + var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) }; + Span idx = stackalloc int[8]; + + Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx)); + Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx)); + } + + [Fact] + public void SelectForObject_MoreThan8_KeepsNearest8() + { + // 10 candidate lights all in range; expect the 8 nearest the object centre, + // ascending by distance, with the two farthest dropped. + var snapshot = new LightSource[10]; + for (int i = 0; i < 10; i++) + snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range + + Span idx = stackalloc int[8]; + int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); + + Assert.Equal(8, n); + // Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest + // (indices 8,9 / dist 9,10) are evicted. + for (int k = 0; k < 8; k++) + Assert.Equal(k, idx[k]); + } + + [Fact] + public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre() + { + // Same snapshot, same object centre → identical selection regardless of + // where any "camera" is (the method takes no camera). This is the property + // that kills the "lights up as I approach" popping. + var snapshot = new[] + { + MakePoint(new Vector3(2, 0, 0), range: 10f), + MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0 + }; + Span a = stackalloc int[8]; + Span b = stackalloc int[8]; + int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a); + int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b); + + Assert.Equal(1, na); + Assert.Equal(na, nb); + Assert.Equal(a[0], b[0]); + } }