merge: bring main (A7 lighting Fix A–D + UN-7 + #140 Fix D) into the D.5 branch

Integrates main's 19 commits (A7 outdoor/indoor torch lighting Fix A/B/C/D,
GlobalLightPacker, shader updates, UN-7) under the D.5 toolbar/item-model stack
(D.5.1/D.5.2/D.5.4/D.5.3a). Auto-merged cleanly except docs/ISSUES.md.

Conflict resolved: both lineages used #140 for different issues. Kept main's
#140 = "A7 Fix D" (resolved); renumbered the toolbar/selected-object issue to
#141 (note added; this branch's commits/spec still reference #140 — immutable).
The register auto-merged (AP-46 cites file:line, not #140; UN-7 keeps #140=Fix D).

Build + full suite green on the merged tree (2,713 passed / 4 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-20 12:01:20 +02:00
commit 31d7ffd253
27 changed files with 2327 additions and 103 deletions

View file

@ -8121,6 +8121,17 @@ 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);
_envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2)
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);

View file

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

View file

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

View file

@ -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
@ -95,10 +122,107 @@ uniform mat4 uViewProjection;
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun)
// 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;
};
// 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);
// 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
// nothing (retail's hardware path). toL is un-normalised (length d).
float angular = (uLightingMode == 1)
? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake)
: max(0.0, dot(N, toL)); // plain Lambert (object/hardware)
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);
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 — OBJECT path only (mode 0). retail's EnvCell path
// (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so
// EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4).
if (uLightingMode == 0) {
int activeLights = int(uCellAmbient.w);
for (int i = 0; i < 8; ++i) {
if (i >= activeLights) break;
if (int(uLights[i].posAndKind.w) != 0) continue; // directional only
vec3 Ldir = -uLights[i].dirAndRange.xyz;
float ndl = max(0.0, dot(N, Ldir));
lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl;
}
}
// POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's
// SetStaticLightingVertexColors sums the static point lights from BLACK and
// clamps the SUM to [0,1] before anything else (a baked emissive term), so a
// few warm intensity-100 torches can't push the whole pixel to white the way
// folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor
// (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged.
vec3 pointAcc = vec3(0.0);
int base = instanceIndex * 8;
for (int k = 0; k < 8; ++k) {
int gi = instanceLightIdx[base + k];
if (gi < 0) continue;
pointAcc += pointContribution(N, worldPos, gLights[gi]);
}
lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive)
return lit; // frag still does the final min(lit, 1.0)
}
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 +247,7 @@ void main() {
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal);
vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights)
vTexCoord = aTexCoord;
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];

View file

@ -88,6 +88,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private uint _clipSlotBuffer;
private uint[] _clipSlotData = Array.Empty<uint>();
// A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state,
// like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last
// left bound. binding=4 = global point-light snapshot (same data/indices as the
// dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance.
private uint _globalLightsSsbo; // binding=4
private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16];
private uint _instLightSetSsbo; // binding=5
private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
private System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? _pointSnapshot;
private readonly System.Collections.Generic.Dictionary<uint, int[]> _cellLightSetCache = new();
// Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
// SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
// our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
@ -231,6 +242,18 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
// A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set.
_gl.GenBuffers(1, out _globalLightsSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw);
_gl.GenBuffers(1, out _instLightSetSsbo);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)),
null, GLEnum.DynamicDraw);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
@ -262,6 +285,17 @@ public sealed unsafe class EnvCellRenderer : IDisposable
public void SetClipRouting(IReadOnlyDictionary<uint, int>? cellIdToSlot)
=> _cellIdToSlot = cellIdToSlot;
/// <summary>
/// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot
/// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside
/// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets
/// reference this snapshot, which is also uploaded to binding=4 here, so the
/// pass is self-contained. Null/empty -> shells receive no point lights.
/// </summary>
public void SetPointSnapshot(
System.Collections.Generic.IReadOnlyList<AcDream.Core.Lighting.LightSource>? snapshot)
=> _pointSnapshot = snapshot;
// ---------------------------------------------------------------------------
// GetEnvCellGeomId
// Verbatim copy of WB EnvCellRenderManager.cs:94-103.
@ -843,6 +877,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// WB EnvCellRenderManager.cs:406-409: uniform state setup.
_shader.SetInt("uRenderPass", (int)renderPass);
_shader.SetInt("uFilterByCell", 0);
_shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun)
// Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when
// moving"): upload uViewProjection HERE rather than inheriting it from
@ -997,6 +1032,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
}
}
// ---------------------------------------------------------------------------
// GetCellLightSet (A7 Fix D D-2 helper)
// Per-cell up-to-8 point lights, cached per frame. Camera-independent, like
// WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds.
// ---------------------------------------------------------------------------
// A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world
// bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet).
// Cached per frame; unused slots are -1 (shader adds no point light there).
private int[] GetCellLightSet(uint cellId)
{
if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached;
var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject];
System.Array.Fill(set, -1);
var snap = _pointSnapshot;
if (snap is { Count: > 0 } &&
_landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) &&
lb.EnvCellBounds.TryGetValue(cellId, out var b))
{
Vector3 center = (b.Min + b.Max) * 0.5f;
float radius = (b.Max - b.Min).Length() * 0.5f;
AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set);
}
_cellLightSetCache[cellId] = set;
return set;
}
// ---------------------------------------------------------------------------
// RenderModernMDIInternal
// Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant).
@ -1016,6 +1080,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
int passIdx = (int)renderPass;
if (passIdx < 0 || passIdx > 2) return;
// A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in
// GetCellLightSet below). Clear once here so each cell gets a fresh lookup
// using this frame's _pointSnapshot. Called for EVERY pass (opaque AND
// transparent); the cache entries are stable within a frame since PointSnapshot
// doesn't change between Render calls, so clearing once (at the opaque pass)
// and leaving stale entries for the transparent pass would also be correct, but
// clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet.
_cellLightSetCache.Clear();
// §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads.
// Without the global VAO nothing can draw, and returning AFTER the pass state
// was established leaked it (same early-out shape as the totalDraws==0 leak —
@ -1213,6 +1286,35 @@ public sealed unsafe class EnvCellRenderer : IDisposable
(nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
}
// A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms,
// keyed on the cell each shell instance belongs to (mirrors _clipSlotData).
int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject;
if (_lightSetData.Length < uniqueInstanceCount * lightStride)
_lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)];
for (int i = 0; i < uniqueInstanceCount; i++)
{
int[] cellSet = GetCellLightSet(allInstances[i].CellId);
System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride);
}
// A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set).
int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int glUploadCount = lightCount > 0 ? lightCount : 1;
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)),
null, GLEnum.DynamicDraw);
fixed (float* gp = _globalLightData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw);
fixed (int* lp = _lightSetData)
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp);
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
// (globalVao validated at the top of the method — a return here would leak the
// pass state established above.)
@ -1228,6 +1330,8 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
BindClipRegionBinding2();
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2)
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2)
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
@ -1443,5 +1547,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2)
if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2)
}
}

View file

@ -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[GlobalLightPacker.FloatsPerLight * 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<LightSource>? _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
}
/// <summary>
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
/// (<see cref="LightManager.PointSnapshot"/>). Call once per frame BEFORE
/// <see cref="Draw"/>. The dispatcher uploads it to binding=4 and selects each
/// object's up-to-8 lights from it (<see cref="LightManager.SelectForObject"/>)
/// by the object's bounding sphere — camera-independent. Pass null/empty to
/// disable per-object point lights (only ambient + sun render).
/// </summary>
public void SetSceneLights(IReadOnlyList<LightSource>? pointSnapshot)
=> _pointSnapshot = pointSnapshot;
/// <summary>
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
/// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. The
@ -861,6 +893,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_indoorProbeFrameCounter++;
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
_shader.SetInt("uLightingMode", 0);
// #128 self-heal: fresh re-request dedup per Draw pass.
_missRequested.Clear();
@ -888,7 +923,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 +1088,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 +1390,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 +1422,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 +1514,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 +1806,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
}
/// <summary>
/// Fix B: pack <see cref="_pointSnapshot"/> into the binding=4 global light
/// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes,
/// matching mesh_modern.vert's <c>GlobalLight</c>). 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).
/// </summary>
private unsafe void UploadGlobalLights()
{
int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
int count = n > 0 ? n : 1; // never zero-size
// Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats.
fixed (float* gp = _globalLightData)
UploadSsbo(_globalLightsSsbo, 4, gp,
count * GlobalLightPacker.FloatsPerLight * sizeof(float));
}
/// <summary>
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
@ -1936,6 +2016,75 @@ 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
}
/// <summary>
/// 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 (<see cref="LightManager.SelectForObject"/>), so
/// a static building's torches stay constant as the viewer moves. Fills
/// <see cref="_currentEntityLightSet"/>; unused slots are -1. On the no-lights
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
///
/// <para>
/// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN +
/// ambient ONLY — never the static wall torches. The per-object torch step
/// (<c>minimize_object_lighting</c>, 0x0054d480) runs ONLY in the indoor stage:
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398) calls it under
/// <c>if (Render::useSunlight == 0)</c>, and the outdoor landscape stage runs
/// <c>Render::useSunlightSet(1)</c> (<c>PView::DrawCells</c> 0x005a485a, right
/// before <c>LScape::draw</c> which draws buildings/scenery). So a building
/// EXTERIOR shell (<see cref="WorldEntity.IsBuildingShell"/>,
/// <see cref="WorldEntity.ParentCellId"/> = null) and all outdoor scenery /
/// creatures get the sun, not torches. We mirror that: only objects parented to
/// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so
/// the sun path alone lights them. This is what made the Holtburg meeting-hall
/// facade wash out warm — the dat's intensity-100 wall torches (range
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
/// The indoor "no sun" half is already handled by the global sun kill when the
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// </para>
/// </summary>
private void ComputeEntityLightSet(WorldEntity entity)
{
Array.Fill(_currentEntityLightSet, -1);
var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return;
// Retail useSunlight gate: outdoor objects receive no per-object torches.
if (!IndoorObjectReceivesTorches(entity.ParentCellId)) 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);
}
/// <summary>
/// Retail's <c>useSunlight</c> gate for per-object torch lighting, as a pure
/// predicate. An object receives the static wall torches (the indoor
/// <c>minimize_object_lighting</c> pass) ONLY when it is parented to an EnvCell
/// — an interior cell, by the AC convention <c>(cellId &amp; 0xFFFF) &gt;= 0x0100</c>.
/// Outdoor objects (building shells with null <paramref name="parentCellId"/>,
/// outdoor scenery in a land sub-cell <c>0x0001..0x00FF</c>, outdoor creatures)
/// are sun-lit only and return false. Mirrors
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398): torches enabled iff
/// <c>Render::useSunlight == 0</c>, which is true only in the indoor draw stage.
/// </summary>
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
=> parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u;
/// <summary>
/// Fix B: append the current entity's 8-slot light set to a group's
/// <see cref="InstanceGroup.LightSets"/>, parallel to its Matrices (one
/// 8-int block per instance), mirroring <c>grp.Slots.Add</c>.
/// </summary>
private void AppendCurrentLightSet(InstanceGroup grp)
{
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
grp.LightSets.Add(_currentEntityLightSet[k]);
}
private void ClassifyBatches(
@ -1993,6 +2142,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 +2222,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 +2409,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<uint> 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<int> LightSets = new();
}
}