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:
parent
146a963aeb
commit
ea4f52ec51
11 changed files with 338 additions and 16 deletions
|
|
@ -524,7 +524,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
|||
- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio <datdir> [--layout 0xNNNN | --dump <slug>] [--screenshot <png>]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed.
|
||||
- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct.
|
||||
- **✓ SHIPPED — Wave 3.3 unified primary-click router.** `ItemInteractionController.OfferPrimaryClick` returns `NotActive` / `ConsumedSuccess` / `ConsumedRejected`; inventory, bags, main pack, paperdoll, toolbar, radar, and world clicks all offer target mode before local fallback. Rejected targets cannot drift selection or open/use the clicked object. Issue #197 and AP-107 retired; retained matrix and live ACE-provisioned healing gate user-confirmed. The 3-D doll routes through authored hit mask `0x100001D6` (resolved `UiButton` above viewport `0x100001D5`), and local health prefers authoritative private-vital state.
|
||||
- **✓ SHIPPED — retail world selection + vivid target indicator (2026-07-17).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..4` to the four installed 12×12 corner surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle plus separate-edge 8-pixel viewport clamp. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`.
|
||||
- **✓ SHIPPED — retail world selection + complete SmartBox feedback (2026-07-17).** The projected Setup-sphere rectangle and independent physics-wall ray are deleted. The normal visible-mesh traversal now publishes server-object parts through their exact drawing-BSP spheres; `RetailWorldPicker` ports the camera-eye ray, per-part local transform, double-precision retail sphere solve, first visual-polygon hit in DAT order, one-sided/convex-edge rules, and global polygon-over-sphere arbitration. The retained gameplay UI resolves client-enum category `0x10000009` values `1..4` to the four installed 12×12 corner surfaces, colorizes them with the radar-blip color, and ports the exact Setup-sphere rectangle plus separate-edge 8-pixel viewport clamp. Successful select/use/targeted-use/drop clicks also run SmartBox's separate 0.8-second HIGH/LOW/HIGH/LOW material-lighting pulse with the exact retail luminosity/diffuse replacements before restoring authored rendering. Issues #70/#71 closed; AP-29/TS-16 retired. Research: `docs/research/2026-07-17-retail-world-selection-pseudocode.md`.
|
||||
- **✓ SHIPPED — Wave 4.1 retail quick-slot input.** Bare `1..9` use toolbar slots 0..8, Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a shortcut to the selected owned/eligible object in retail's first empty slot. `ToolbarInputController` is the focused semantic adapter; `ToolbarController` owns `UseShortcut` target-before-use/select ordering and auto-slot eligibility. Keybinding schema v2 migrates only the exact old default Ctrl+number chords, so custom bindings retain their chosen action. Full automated conformance and live user gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.2 exact shortcut records.** One Core-owned `ShortcutEntry` preserves retail's signed `index_`, unsigned `objectID_`, and raw 32-bit `spellID_` through PlayerDescription parse, the nullable 18-slot session store, drag snapshots/reindexing, WorldSession, and exact 12-byte AddShortcut packing. The visible `gmToolbarUI` remains object-only; `favorite_spells_[8]` remains a separate future spell-bar source. AP-103 retired. Full automated conformance and live mutate→clean-relog persistence gate passed 2026-07-11.
|
||||
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
|
||||
|
|
|
|||
|
|
@ -139,7 +139,64 @@ else:
|
|||
show the on-screen root and hide the off-screen root
|
||||
```
|
||||
|
||||
There is no selected-world-mesh tint or luminosity mutation in
|
||||
`CPhysicsPart::Draw`, `ACCWeenieObject::SetSelected`, or the world mesh draw
|
||||
path. The visible notification is the colorized four-corner DAT indicator. A
|
||||
mesh shader tint would therefore be a new effect, not a retail port.
|
||||
The indicator is persistent selection presentation. Retail also has a separate,
|
||||
short-lived click confirmation owned by SmartBox; it is not implemented in
|
||||
`CPhysicsPart::Draw` or `ACCWeenieObject::SetSelected`.
|
||||
|
||||
## SmartBox click lighting pulse
|
||||
|
||||
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ 0x004E5AD0`
|
||||
|
||||
```text
|
||||
when FindObject succeeds for Select, Examine, Use, Drop, Drag, or TargetedUse:
|
||||
if another object is currently pulsing:
|
||||
RestoreLighting(previous object)
|
||||
SetLighting(found object, luminosity=0.99, diffuse=1.0) // HIGH
|
||||
flipCount = 1
|
||||
nextFlip = Timer::cur_time + 0.2 seconds
|
||||
|
||||
continue with the reason-specific select/examine/use/drop behavior
|
||||
```
|
||||
|
||||
`UIElement_SmartBoxWrapper::Global_Loop @ 0x004E5620`
|
||||
|
||||
```text
|
||||
if flipCount != 0 and Timer::cur_time >= nextFlip:
|
||||
flipCount += 1
|
||||
if flipCount < 5:
|
||||
lightingMode = ((flipCount & 1) != 0) + 1
|
||||
nextFlip = Timer::cur_time + 0.2 seconds
|
||||
else:
|
||||
flipCount = 0
|
||||
lightingMode = RESTORE
|
||||
ApplyLighting(pulsing object, lightingMode)
|
||||
```
|
||||
|
||||
`UIElement_SmartBoxWrapper::ApplyLighting @ 0x004E5320`
|
||||
|
||||
```text
|
||||
RESTORE: CPhysicsObj::RestoreLighting(object)
|
||||
LOW: CPhysicsObj::SetLighting(object, luminosity=0.0, diffuse=0.35)
|
||||
HIGH: CPhysicsObj::SetLighting(object, luminosity=0.99, diffuse=1.0)
|
||||
```
|
||||
|
||||
The complete cadence is therefore HIGH immediately, LOW at 0.2 seconds, HIGH
|
||||
at 0.4, LOW at 0.6, and authored material lighting restored at 0.8. A delayed
|
||||
frame advances only one step because retail schedules the next flip from the
|
||||
current `Timer::cur_time`; it does not drain missed intervals.
|
||||
|
||||
`CPhysicsObj::SetLighting @ 0x00511A80` forwards to every physics part through
|
||||
`CPartArray::SetLightingInternal @ 0x00518490`. `CPhysicsPart::SetLighting @
|
||||
0x0050E400` copies the material when needed, then calls
|
||||
`CMaterial::SetLuminositySimple` followed by `CMaterial::SetDiffuseSimple`.
|
||||
The modern renderer's equivalent is an instance-scoped material replacement:
|
||||
|
||||
```text
|
||||
lit = luminosity + diffuse * sceneLighting
|
||||
rgb = sampledTexture.rgb * clamp(lit, 0, 1)
|
||||
```
|
||||
|
||||
Normal/restored rendering uses `(luminosity=0, diffuse=1)` in the current object
|
||||
shader. The pulse is distinct from the persistent four-corner indicator and
|
||||
must restart on every successfully resolved SmartBox click, even when the click
|
||||
is consumed by targeted use instead of changing the selected object.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
using AcDream.App.Rendering.Selection;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class RetailSelectionLightingPulseTests
|
||||
{
|
||||
[Fact]
|
||||
public void ClickRunsRetailHighLowHighLowRestoreCadence()
|
||||
{
|
||||
double now = 10d;
|
||||
var pulse = new RetailSelectionLightingPulse(() => now);
|
||||
|
||||
pulse.Start(0x5000_1234u);
|
||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
|
||||
|
||||
now = 10.199;
|
||||
pulse.Tick();
|
||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
|
||||
|
||||
now = 10.2;
|
||||
pulse.Tick();
|
||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
|
||||
|
||||
now = 10.4;
|
||||
pulse.Tick();
|
||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.High);
|
||||
|
||||
now = 10.6;
|
||||
pulse.Tick();
|
||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
|
||||
|
||||
now = 10.8;
|
||||
pulse.Tick();
|
||||
Assert.False(pulse.TryGet(0x5000_1234u, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LongFrameAdvancesOnlyOneRetailGlobalLoopFlip()
|
||||
{
|
||||
double now = 1d;
|
||||
var pulse = new RetailSelectionLightingPulse(() => now);
|
||||
pulse.Start(0x5000_1234u);
|
||||
|
||||
now = 20d;
|
||||
pulse.Tick();
|
||||
|
||||
AssertLighting(pulse, 0x5000_1234u, RetailSelectionLighting.Low);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewClickRestoresOldTargetAndRestartsHighOnNewTarget()
|
||||
{
|
||||
double now = 4d;
|
||||
var pulse = new RetailSelectionLightingPulse(() => now);
|
||||
pulse.Start(0x5000_0001u);
|
||||
|
||||
now = 4.2;
|
||||
pulse.Tick();
|
||||
AssertLighting(pulse, 0x5000_0001u, RetailSelectionLighting.Low);
|
||||
|
||||
pulse.Start(0x5000_0002u);
|
||||
|
||||
Assert.False(pulse.TryGet(0x5000_0001u, out _));
|
||||
AssertLighting(pulse, 0x5000_0002u, RetailSelectionLighting.High);
|
||||
}
|
||||
|
||||
private static void AssertLighting(
|
||||
RetailSelectionLightingPulse pulse,
|
||||
uint serverGuid,
|
||||
RetailSelectionLighting expected)
|
||||
{
|
||||
Assert.True(pulse.TryGet(serverGuid, out var actual));
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,8 +7,8 @@ namespace AcDream.App.Tests.Rendering.Wb;
|
|||
public class InstanceGroupClearTests
|
||||
{
|
||||
// #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds
|
||||
// five per-instance parallel lists — Matrices, Slots, LightSets, IndoorFlags,
|
||||
// Opacities — appended in lockstep (one entry per drawn instance) every frame. The
|
||||
// six per-instance parallel lists — Matrices, Slots, LightSets, IndoorFlags,
|
||||
// Opacities, SelectionLighting — appended in lockstep (one entry per drawn instance) every frame. The
|
||||
// per-frame reset must clear ALL of them. #188 added Opacities but left it out of
|
||||
// the inline clear loop, so it grew one float per instance per frame forever; as
|
||||
// List<float>'s backing array doubled it produced ~128 MB / ~512 MB LOH float[]
|
||||
|
|
@ -23,6 +23,7 @@ public class InstanceGroupClearTests
|
|||
grp.LightSets.Add(-1);
|
||||
grp.IndoorFlags.Add(0u);
|
||||
grp.Opacities.Add(1.0f);
|
||||
grp.SelectionLighting.Add(new Vector2(0f, 1f));
|
||||
|
||||
grp.ClearPerInstanceData();
|
||||
|
||||
|
|
@ -31,5 +32,6 @@ public class InstanceGroupClearTests
|
|||
Assert.Empty(grp.LightSets);
|
||||
Assert.Empty(grp.IndoorFlags);
|
||||
Assert.Empty(grp.Opacities); // #193 — the list that leaked
|
||||
Assert.Empty(grp.SelectionLighting);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue