fix(selection): port SmartBox click lighting pulse

Port the retail high/low material-lighting cadence for successful world clicks, keep it instance-scoped in the modern renderer, and restore authored lighting after 0.8 seconds. Correct the selection oracle and pin timing plus per-frame buffer lifecycle with tests.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 23:00:04 +02:00
parent 146a963aeb
commit ea4f52ec51
11 changed files with 338 additions and 16 deletions

View file

@ -12379,6 +12379,8 @@ public sealed class GameWindow : IDisposable
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
{
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
if (target != 0u)
_retailSelectionScene?.BeginLightingPulse(target);
_itemInteractionController?.PlaceIn3D(itemPayload, target);
}
}
@ -13129,6 +13131,11 @@ public sealed class GameWindow : IDisposable
if (picked is uint guid)
{
// Retail UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound
// @ 0x004E5AD0 pulses every successfully found click before it
// branches into select/use/targeted-use behavior.
_retailSelectionScene?.BeginLightingPulse(guid);
if (_itemInteractionController?.OfferPrimaryClick(guid)
is not null and not AcDream.App.UI.ItemPrimaryClickResult.NotActive)
return;

View file

@ -0,0 +1,17 @@
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Supplies the transient material lighting override which retail applies to
/// a world object after SmartBox resolves a click.
/// </summary>
internal interface IRetailSelectionLightingSource
{
/// <summary>Advances the retail SmartBox flip cadence once for this frame.</summary>
void TickLighting();
/// <summary>
/// Returns the current CMaterial luminosity/diffuse replacement for an
/// object, or false when its authored material values must be restored.
/// </summary>
bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting);
}

View file

@ -0,0 +1,101 @@
using System.Diagnostics;
namespace AcDream.App.Rendering.Selection;
/// <summary>
/// Replacement values passed by retail to CPhysicsObj::SetLighting.
/// CMaterial receives luminosity first and diffuse second.
/// </summary>
internal readonly record struct RetailSelectionLighting(float Luminosity, float Diffuse)
{
public static readonly RetailSelectionLighting Normal = new(0f, 1f);
public static readonly RetailSelectionLighting Low = new(0f, 0.35f);
public static readonly RetailSelectionLighting High = new(0.99f, 1f);
}
/// <summary>
/// Retail SmartBox click-confirmation lighting pulse.
///
/// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0
/// applies HIGH immediately. Global_Loop @ 0x004E5620 then advances
/// LOW/HIGH/LOW/RESTORE at 0.2-second intervals. A new click restores the old
/// target implicitly and starts the sequence again on the new target.
/// </summary>
internal sealed class RetailSelectionLightingPulse
{
internal const double FlipIntervalSeconds = 0.2;
private readonly Func<double> _now;
private uint _serverGuid;
private int _flipCount;
private double _nextFlip;
private RetailSelectionLighting _lighting;
public RetailSelectionLightingPulse(Func<double>? now = null)
=> _now = now ?? MonotonicSeconds;
public void Start(uint serverGuid)
{
if (serverGuid == 0u)
{
Clear();
return;
}
_serverGuid = serverGuid;
_flipCount = 1;
_lighting = RetailSelectionLighting.High;
_nextFlip = _now() + FlipIntervalSeconds;
}
/// <summary>
/// Advances at most one flip per frame, matching retail Global_Loop. The
/// next deadline is based on the current frame time, so a long frame does
/// not replay multiple material mutations in one draw.
/// </summary>
public void Tick()
{
if (_flipCount == 0)
return;
double now = _now();
if (now < _nextFlip)
return;
int nextCount = _flipCount + 1;
if (nextCount >= 5)
{
Clear();
return;
}
_flipCount = nextCount;
_lighting = (nextCount & 1) != 0
? RetailSelectionLighting.High
: RetailSelectionLighting.Low;
_nextFlip = now + FlipIntervalSeconds;
}
public bool TryGet(uint serverGuid, out RetailSelectionLighting lighting)
{
if (_flipCount != 0 && serverGuid != 0u && serverGuid == _serverGuid)
{
lighting = _lighting;
return true;
}
lighting = default;
return false;
}
public void Clear()
{
_serverGuid = 0u;
_flipCount = 0;
_nextFlip = 0d;
_lighting = default;
}
private static double MonotonicSeconds()
=> Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency;
}

View file

@ -9,9 +9,10 @@ namespace AcDream.App.Rendering.Selection;
/// The renderer builds one frame while input queries the previously completed
/// frame, avoiding partial visibility state during multi-slice portal drawing.
/// </summary>
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink
internal sealed class RetailSelectionScene : IRetailSelectionRenderSink, IRetailSelectionLightingSource
{
private readonly RetailSelectionGeometryCache _geometry;
private readonly RetailSelectionLightingPulse _lightingPulse;
private List<RetailSelectionPart> _building = new();
private List<RetailSelectionPart> _published = new();
private readonly HashSet<PartKey> _buildingKeys = new();
@ -21,8 +22,26 @@ internal sealed class RetailSelectionScene : IRetailSelectionRenderSink
private readonly record struct PartKey(uint LocalEntityId, int PartIndex, uint GfxObjId);
public RetailSelectionScene(RetailSelectionGeometryCache geometry)
=> _geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
public RetailSelectionScene(
RetailSelectionGeometryCache geometry,
RetailSelectionLightingPulse? lightingPulse = null)
{
_geometry = geometry ?? throw new ArgumentNullException(nameof(geometry));
_lightingPulse = lightingPulse ?? new RetailSelectionLightingPulse();
}
/// <summary>
/// Starts retail's SmartBox click pulse independently of persistent target
/// selection. Examine, use, and targeted-use clicks receive the same pulse.
/// </summary>
public void BeginLightingPulse(uint serverGuid)
=> _lightingPulse.Start(serverGuid);
public void TickLighting()
=> _lightingPulse.Tick();
public bool TryGetLighting(uint serverGuid, out RetailSelectionLighting lighting)
=> _lightingPulse.TryGet(serverGuid, out lighting);
public void BeginFrame()
{

View file

@ -8,6 +8,7 @@ in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped li
in flat uvec2 vTextureHandle;
in flat uint vTextureLayer;
in flat float vOpacityMultiplier; // #188
in flat vec2 vSelectionLighting; // x=luminosity, y=diffuse
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
// 0 = opaque pass — discard fragments with alpha < 0.95
@ -84,8 +85,10 @@ void main() {
if (color.a < 0.05) discard;
}
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights).
vec3 lit = vLit;
// Per-vertex Gouraud lighting from the vertex shader (ambient + capped
// lights), passed through retail CMaterial's two click-pulse replacements:
// emissive/luminosity + diffuse * scene lighting. Normal is (0,1).
vec3 lit = vec3(vSelectionLighting.x) + vSelectionLighting.y * vLit;
// #176 stripe-hunt mode 3: show the raw per-vertex light field (texture
// ignored). Stripes visible HERE = a vertex-lighting artifact; absent =

View file

@ -7,10 +7,6 @@ layout(location = 2) in vec2 aTexCoord;
struct InstanceData {
mat4 transform;
// Reserved for Phase B.4 follow-up (selection-blink retail-faithful
// highlight): vec4 highlightColor; — extend stride here, increase the
// _instanceSsbo upload size in WbDrawDispatcher, add a flat varying out,
// and consume in mesh_modern.frag.
};
struct BatchData {
@ -115,6 +111,15 @@ layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
float instanceAlpha[];
};
// Retail SmartBox click confirmation. One vec2 per OBJECT instance, parallel
// to binding=0: x = CMaterial luminosity, y = CMaterial diffuse. Normal
// rendering is (0,1); SmartBox alternates LOW=(0,.35) and HIGH=(.99,1).
// EnvCellRenderer uses uLightingMode=1 and deliberately never reads this
// object-only binding.
layout(std430, binding = 8) readonly buffer InstanceSelectionLightingBuf {
vec2 instanceSelectionLighting[];
};
// 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
@ -277,11 +282,15 @@ out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped l
out flat uvec2 vTextureHandle;
out flat uint vTextureLayer;
out flat float vOpacityMultiplier; // #188
out flat vec2 vSelectionLighting;
void main() {
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
mat4 model = Instances[instanceIndex].transform;
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
vSelectionLighting = (uLightingMode == 0)
? instanceSelectionLighting[instanceIndex]
: vec2(0.0, 1.0);
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;

View file

@ -87,6 +87,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly WbMeshAdapter _meshAdapter;
private readonly EntitySpawnAdapter _entitySpawnAdapter;
private readonly IRetailSelectionRenderSink? _selectionSink;
private readonly IRetailSelectionLightingSource? _selectionLighting;
private readonly BindlessSupport _bindless;
@ -165,6 +166,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// a clone of _indoorData / _instIndoorSsbo, one binding higher.
private uint _instAlphaSsbo;
private float[] _alphaData = new float[256];
// Retail SmartBox click confirmation: per-instance CMaterial luminosity /
// diffuse replacement (binding=8), parallel to the transform buffer.
private uint _instSelectionLightingSsbo;
private Vector2[] _selectionLightingData = new Vector2[256];
// 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;
@ -178,6 +183,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in
// AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[].
private bool _currentEntityIndoor;
private Vector2 _currentEntitySelectionLighting = new(0f, 1f);
// 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
@ -375,6 +381,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_cache = classificationCache;
_translucencyFades = translucencyFades;
_selectionSink = selectionSink;
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_instanceSsbo = _gl.GenBuffer();
@ -385,6 +392,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
_instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7
_instSelectionLightingSsbo = _gl.GenBuffer(); // SmartBox binding=8
}
/// <summary>
@ -927,6 +935,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
EntitySet set = EntitySet.All)
{
_shader.Use();
_selectionLighting?.TickLighting();
_indoorProbeFrameCounter++;
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
@ -1131,6 +1140,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
_currentEntitySelectionLighting =
_selectionLighting?.TryGetLighting(entity.ServerGuid, out var lighting) == true
? new Vector2(lighting.Luminosity, lighting.Diffuse)
: new Vector2(0f, 1f);
// #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
@ -1508,6 +1521,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_alphaData.Length < totalInstances)
_alphaData = new float[totalInstances + 256];
if (_selectionLightingData.Length < totalInstances)
_selectionLightingData = new Vector2[totalInstances + 256];
_opaqueDraws.Clear();
_translucentDraws.Clear();
@ -1546,6 +1562,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// #188: Opacities[] is parallel to Matrices[]; write at the same
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
_alphaData[cursor] = grp.Opacities[i];
// SmartBox CMaterial replacement, parallel to Matrices.
_selectionLightingData[cursor] = grp.SelectionLighting[i];
cursor++;
}
@ -1643,6 +1661,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
fixed (float* ap = _alphaData)
UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float));
// SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert
// reads this only for the object path (uLightingMode=0), so EnvCell's
// independent mode-1 renderer does not consume this binding.
fixed (Vector2* hp = _selectionLightingData)
UploadSsbo(_instSelectionLightingSsbo, 8, hp, totalInstances * sizeof(float) * 2);
// 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
@ -2189,6 +2213,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// state for entities whose animation hooks fired — so a cached
// instance can never be mid-fade. Always unmodified opacity.
grp.Opacities.Add(1.0f);
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
}
/// <summary>
@ -2370,6 +2395,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
collector?.Add(new CachedBatch(key, texHandle, restPose));
}
}
@ -2453,6 +2479,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7
if (_instSelectionLightingSsbo != 0) _gl.DeleteBuffer(_instSelectionLightingSsbo); // SmartBox binding=8
if (_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
@ -2660,6 +2687,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
public readonly List<float> Opacities = new();
// Retail SmartBox click lighting, parallel to Matrices. Each vec2 is
// (luminosity, diffuse) and is uploaded to binding=8.
public readonly List<Vector2> SelectionLighting = new();
/// <summary>
/// Resets every per-instance parallel list for a new frame. These lists are
/// appended in lockstep (one entry per drawn instance) during group build, so
@ -2677,6 +2708,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
LightSets.Clear();
IndoorFlags.Clear();
Opacities.Clear();
SelectionLighting.Clear();
}
}
}