feat(#188): fading-wall + sliding-door translucency; hold open past animation settle
Lands the fading-secret-door feature and fixes the door "flip-back" that surfaced while testing it. #188 — fading-wall doors (e.g. "Pedestal Weak Spot") fade their wall part out via TransparentPartHook instead of swinging: - TranslucencyHookSink consumes TransparentPartHook -> TranslucencyFadeManager (per-(entity,part) linear translucency ramp; holds at End frame). - WbDrawDispatcher: new per-instance alpha SSBO (binding 7); ClassifyBatches takes opacityMultiplier (1 - translucency, per CMaterial::SetTranslucencySimple 0x005396f0) forcing AlphaBlend; fully-invisible parts skipped. - mesh_modern.vert/.frag: binding-7 InstanceAlphaBuf -> vOpacityMultiplier -> FragColor.a *= vOpacityMultiplier. - Register AP-89: the fade multiplies sampled texture alpha, not a separate D3D9 material alpha channel (observably identical for texture-alpha==1 surfaces). Door flip-back fix (affected BOTH #188 fading walls AND #187 sliding doors): a door/wall that finished opening holds a single unchanging frame, so the uncommitted IsEntityCurrentlyMoving cache-bypass narrowing dropped it onto the Tier-1 static cache -- which only remembers the REST pose + opacity 1.0 -- snapping it visually shut/opaque while physics stayed open. Reverted that narrowing: every Sequencer entity stays on the per-frame path (live pose + live fade opacity), the known-good pre-optimization behavior. The per-frame CPU cost that narrowing chased was a Debug-build artifact -- Release is GPU-bound (~200 fps in Sawato, measured), so the unconditional add is free where it matters. Left a code comment barring re-introduction. Tests: full Core suite green (2649 passed, 2 skipped). Live visual gate PASSED -- both fading-wall and sliding doors hold open. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
e2e285b855
commit
3284dd0aed
12 changed files with 625 additions and 14 deletions
|
|
@ -104,7 +104,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 77 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45; AP-86/AP-87 added 2026-07-07 — the remote-creature de-overlap #184: shadow-follows-resolved via a movement-gated re-flood, and the NPC MoveOrTeleport 4 m/no-Sequencer placement snap; AP-88 added 2026-07-08 — the remote omega player/NPC fork surviving #184 Slice 2b's fork collapse; AP-86 updated same day — the per-UP raw-pos shadow sync is now retired for players too)
|
## 3. Documented approximation (AP) — 78 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit; AP-84 added 2026-07-07 — CSphere collide_with_point PerfectClip TOI decoded via ACE, dead in M1.5, alongside the CSphere family port that retired TS-45; AP-86/AP-87 added 2026-07-07 — the remote-creature de-overlap #184: shadow-follows-resolved via a movement-gated re-flood, and the NPC MoveOrTeleport 4 m/no-Sequencer placement snap; AP-88 added 2026-07-08 — the remote omega player/NPC fork surviving #184 Slice 2b's fork collapse; AP-86 updated same day — the per-UP raw-pos shadow sync is now retired for players too; AP-89 added 2026-07-08 — #188 fading-wall TransparentPartHook multiplies sampled texture alpha, not a separate material alpha channel)
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -191,6 +191,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-86 | **Remote SHADOW-follows-resolved via a movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody` → `ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal`→`remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) |
|
| AP-86 | **Remote SHADOW-follows-resolved via a movement-gated per-tick re-flood** (remote-creature de-overlap #184, 2026-07-07): a moving NPC remote's collision shadow is re-registered at its RESOLVED body position (`SyncRemoteShadowToBody` → `ShadowObjects.UpdatePosition`) so neighbours + the player de-overlap / collide against where the monster actually is (== where it renders), and the de-overlap PERSISTS. This is retail-faithful in EFFECT (retail re-registers a moved object's shadow every accepted transition step, `SetPositionInternal`→`remove/add_shadows_to_cells` 0x00515330), but the IMPLEMENTATION differs: acdream runs the FULL `RegisterMultiPart` cell-flood, gated on `|Body−LastShadowSyncPos| > 1 cm`, rather than an in-place sphere translate with cell-relink-only-on-change; and the per-UP raw-worldPos shadow sync is RETIRED for EVERY remote (Slice 2b, 2026-07-08 — was players-only through Slice 1): every remote's shadow (player + NPC) is written ONLY by this per-tick loop + the UP-branch tail, both to the RESOLVED body, since Slice 2b collapsed the player/NPC fork so grounded PLAYER remotes also run the sweep + shadow-follows-resolved. Proven: `RemoteDeOverlapMechanismTests` (with-sync 0.86 m stable vs without-sync <0.40 m; real-interp loop absorbs the stall-blip — incl. the `ConvergingPlayers_RealInterpLoop` player-config pair added in Slice 2b) | `src/AcDream.App/Rendering/GameWindow.cs` (`RemotePhysicsUpdater.SyncRemoteShadowToBody` + the DR-tick movement gate; the NPC UP-branch tail sync + the player UP-branch tail sync; the RETIRED raw-pos sync site — a comment where the players-only `:5669` block used to be); `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`UpdatePosition`) | The movement gate is exact at the de-overlap equilibrium (the resolved position genuinely stops moving there, so no needed sync is skipped) and the flood result is identical to retail's per-step re-register; the cost is the only divergence | A dense town of many animated remotes re-floods per moving creature per tick (Gen0 churn — the MP-track FPS class); a still crowd is gated out. Optimize with an in-place shadow-move if profiling shows it. `rm.CellId==0` on a partial resolve keeps the prior cell (same pre-existing exposure as `:5669`) — only a landblock-crossing on that tick misplaces the shadow | `SetPositionInternal` 0x00515bd0 → `change_cell`/`add_shadows_to_cells` 0x00515330 (registers shadow from resolved `m_position`) |
|
||||||
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
| AP-87 | **NPC MoveOrTeleport placement adds a 4 m body-to-target snap + a no-Sequencer snap** beyond retail's <96 m-unconditional interpolate (remote-creature de-overlap #184, 2026-07-07): retail `CPhysicsObj::MoveOrTeleport` (0x00516330) hard-places only on the teleport-timestamp / cell==0 branch or the ≥96 m far-snap, and InterpolateTo-queues every near correction; acdream ADDS two snap conditions — `|Body.Position − worldPos| > 4 m` (a large correction / an unplaced first-UP body) and `!willBeDrTicked` (no Sequencer to consume the queue). Without them an unplaced body (origin / spawn seed) would enqueue, the InterpolationManager's 100 m far-blip would fire, and the per-tick sweep would run over a huge distance in a cell not containing the body → garbage resolved pos → the reverted attempt's INVISIBLE monster. `firstUp` (`LastServerPosTime<=0`) is a belt hint only — the 4 m guard is the load-bearing backstop | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC MoveOrTeleport routing, `BodySnapThresholdNpc`/`willBeDrTickedNpc`) | acdream's catch-up+sweep needs the body already near the target (a valid nearby cell) for the per-frame sweep to be small; the 4 m snap keeps it there, and retail's own large-correction path (the 100 m far-blip) is upstream of it. The de-overlap sweep also uses the fixed human sphere (R 0.48 / H 1.835) for the mover regardless of creature size, so large packed creatures de-overlap at human radii — inherits **TS-46** | A grounded remote that legitimately lags >4 m from its server pos snaps (a small pop) where retail would slide; a no-Sequencer server-moved entity hard-snaps every UP (no DR smoothing). Both are rare | `CPhysicsObj::MoveOrTeleport` 0x00516330 (near-interpolate <96 m; teleport/cell-0 snap; far-snap ≥96 m); `InterpolationManager` 100 m `AutonomyBlipDistance` (the retail large-correction path) |
|
||||||
| AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) |
|
| AP-88 | **Remote omega is reconstructed with a player/NPC fork retail does not have** (remote-creature de-overlap #184 Slice 2b, 2026-07-08): retail's `UpdateObjectInternal` applies ONE angular-velocity integration to every object; acdream reconstructs remote omega from the wire and keeps a player/NPC split inherited from the two former DR paths — a grounded PLAYER remote applies `ObservedOmega ∥ seqOmega` (falls back to the sequencer's synthesised cycle omega when the wire-TurnCommand-derived `ObservedOmega` is 0 — the "circling player sends RunForward+TurnLeft on ONE UM" case) in the WORLD frame (pre-multiply, `Quaternion.Concatenate`); an NPC or an AIRBORNE body applies `ObservedOmega`-only in the BODY frame (post-multiply, `Quaternion.Multiply`). Both feed the same downstream integrate; `calc_acceleration` zeroes `Body.Omega` for grounded bodies so `UpdatePhysicsInternal` never double-integrates | `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick`, Step 2 omega fork) | For an UPRIGHT body (the only remote pose — creatures/players never pitch or roll; the wire orientation is yaw-only) rotating about world-Z (the only turn axis) the pre- and post-multiply orders COMMUTE and both branches reduce to the same yaw increment; the seqOmega fallback only adds rotation a circling player genuinely has, and applying it to NPCs would spuriously add their baked cycle omega — so the fork is behaviourally faithful for every reachable pose (it is what the pre-2b Path A / Path B already did, now explicit in one method). ALSO: the merge applies omega BEFORE `ComputeOffset` for everyone (Path B's order), whereas pre-2b Path A applied a grounded PLAYER's omega AFTER its compose — so the `ori` fed to the anim-root-motion fallback (`Transform(seqVel·dt, ori)`, used only when the interp queue is empty/head-reached) is yaw-advanced by one tick (~ω·dt ≈ 2° at 2.24 rad/s, non-accumulating, zero while the catch-up is active). Keeping Path B's order leaves the shipped/gate-passed NPC omega untouched and only nudges a turning grounded player's fallback direction ~2° in rare queue-empty frames — cosmetically negligible; adopting Path A's order instead would have perturbed NPCs by the same amount | If a remote ever acquires a non-yaw orientation (pitch/roll — a future flying mount / ragdoll) the two multiplication orders diverge and a player would rotate differently from an NPC at the same omega; collapse to one order + a shared fallback policy then | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (single apply_physics omega integration, no object-class fork); `apply_interpreted_movement` 0x00528600 (retail's server-driven omega source acdream reconstructs) |
|
||||||
|
| AP-89 | **TransparentPartHook fade multiplies the SAMPLED TEXTURE alpha, not a separate material alpha channel** (#188, 2026-07-08 — the fading-wall secret-passage doors, e.g. "Pedestal Weak Spot"): retail's `CPhysicsPart::SetTranslucency` (0x0050e670) → `CMaterial::SetTranslucencySimple` (0x005396f0) REPLACES the D3D9 material's 4 alpha channels wholesale (`Ambient.a = Diffuse.a = Specular.a = Emissive.a = 1 − translucency`) — a per-material alpha that composes with, but is conceptually separate from, the surface's own sampled texture alpha. acdream's `mesh_modern.frag` has no material-alpha concept at all; the port multiplies the runtime fade's opacity multiplier directly against the already-sampled `color.a` (`FragColor = vec4(rgb, color.a * vOpacityMultiplier)`) | `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (final `FragColor` line); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`ClassifyBatches` `opacityMultiplier` param, `InstanceGroup.Opacities`); `src/AcDream.Core/Rendering/TranslucencyFadeManager.cs` | Observably identical to retail for any surface whose base texture alpha is 1.0 everywhere — the Pedestal Weak Spot's stone-wall texture, and the overwhelming majority of AC surfaces, since `color.a * 1.0 == color.a` and the fade multiplier alone then drives the ramp exactly as `1 − translucency` would | A hypothetical object that is BOTH already alpha-keyed/blended from its own texture (stained glass, a flame surface) AND plays a TransparentPartHook fade simultaneously would compound the two alphas (texture-alpha × fade-multiplier) instead of the fade cleanly replacing/overriding the surface's own alpha as retail's material-replace does — such an object would fade darker / more-transparent than retail, not just at retail's rate | `CPhysicsPart::SetTranslucency` 0x0050e670; `CMaterial::SetTranslucencySimple` 0x005396f0 (`alpha = 1 − translucency`, applied to all 4 D3D9 material alpha channels) |
|
||||||
|
|
||||||
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
|
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -799,6 +799,14 @@ public sealed class GameWindow : IDisposable
|
||||||
// from the animation pipeline flip the matching LightSource.IsLit.
|
// from the animation pipeline flip the matching LightSource.IsLit.
|
||||||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||||||
|
|
||||||
|
// #188 — TransparentPartHook fires from the animation pipeline drive
|
||||||
|
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
|
||||||
|
// per frame. Wired into the hook router in OnLoad, advanced once per
|
||||||
|
// frame in the main loop regardless of _animatedEntities membership
|
||||||
|
// (a fade must keep running even if its entity's one-shot cycle ends).
|
||||||
|
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades = new();
|
||||||
|
private AcDream.Core.Rendering.TranslucencyHookSink? _translucencySink;
|
||||||
|
|
||||||
// Phase G.1 sky renderer + shared UBO. Created once the GL context
|
// Phase G.1 sky renderer + shared UBO. Created once the GL context
|
||||||
// exists in OnLoad; shared across every other renderer via
|
// exists in OnLoad; shared across every other renderer via
|
||||||
// binding = 1 so terrain/mesh/instanced/sky all read the same
|
// binding = 1 so terrain/mesh/instanced/sky all read the same
|
||||||
|
|
@ -1425,6 +1433,11 @@ public sealed class GameWindow : IDisposable
|
||||||
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
|
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting);
|
||||||
_hookRouter.Register(_lightingSink);
|
_hookRouter.Register(_lightingSink);
|
||||||
|
|
||||||
|
// #188 — TransparentPartHook (per-part translucency fade, e.g.
|
||||||
|
// the "fading wall" secret-passage doors) routes here.
|
||||||
|
_translucencySink = new AcDream.Core.Rendering.TranslucencyHookSink(_translucencyFades);
|
||||||
|
_hookRouter.Register(_translucencySink);
|
||||||
|
|
||||||
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
|
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
|
||||||
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
|
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
|
||||||
if (!_options.NoAudio)
|
if (!_options.NoAudio)
|
||||||
|
|
@ -2541,7 +2554,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||||||
_classificationCache);
|
_classificationCache, _translucencyFades);
|
||||||
// A.5 T22.5: apply A2C gate from quality preset.
|
// A.5 T22.5: apply A2C gate from quality preset.
|
||||||
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
|
||||||
|
|
||||||
|
|
@ -2648,7 +2661,10 @@ public sealed class GameWindow : IDisposable
|
||||||
_worldState.TryGetLandblock(id, out var lb))
|
_worldState.TryGetLandblock(id, out var lb))
|
||||||
{
|
{
|
||||||
foreach (var ent in lb!.Entities)
|
foreach (var ent in lb!.Entities)
|
||||||
|
{
|
||||||
_lightingSink.UnregisterOwner(ent.Id);
|
_lightingSink.UnregisterOwner(ent.Id);
|
||||||
|
_translucencyFades.ClearEntity(ent.Id); // #188
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_terrain?.RemoveLandblock(id);
|
_terrain?.RemoveLandblock(id);
|
||||||
_physicsEngine.RemoveLandblock(id);
|
_physicsEngine.RemoveLandblock(id);
|
||||||
|
|
@ -4708,6 +4724,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// double-register the new one. (Was gated on logDelete — harmless only
|
// double-register the new one. (Was gated on logDelete — harmless only
|
||||||
// while live weenies registered no lights, which is no longer true.)
|
// while live weenies registered no lights, which is no longer true.)
|
||||||
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
||||||
|
_translucencyFades.ClearEntity(existingEntity.Id); // #188
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -7882,6 +7899,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// sort). Clear the owner's previous registration first: the
|
// sort). Clear the owner's previous registration first: the
|
||||||
// re-apply becomes idempotent, and a first apply is a no-op.
|
// re-apply becomes idempotent, and a first apply is a no-op.
|
||||||
_lightingSink.UnregisterOwner(entity.Id);
|
_lightingSink.UnregisterOwner(entity.Id);
|
||||||
|
_translucencyFades.ClearEntity(entity.Id); // #188
|
||||||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||||||
datSetup,
|
datSetup,
|
||||||
ownerId: entity.Id,
|
ownerId: entity.Id,
|
||||||
|
|
@ -9045,6 +9063,12 @@ public sealed class GameWindow : IDisposable
|
||||||
if (_animatedEntities.Count > 0)
|
if (_animatedEntities.Count > 0)
|
||||||
TickAnimations((float)deltaSeconds);
|
TickAnimations((float)deltaSeconds);
|
||||||
|
|
||||||
|
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
|
||||||
|
// _animatedEntities.Count): a one-shot open-cycle animation can
|
||||||
|
// finish and drop its entity from _animatedEntities before the
|
||||||
|
// fade's own Time has elapsed, but the ramp must keep running.
|
||||||
|
_translucencyFades.AdvanceAll((float)deltaSeconds);
|
||||||
|
|
||||||
// Phase G.1: weather state machine — deterministic per-day roll
|
// Phase G.1: weather state machine — deterministic per-day roll
|
||||||
// + transitions + lightning flash.
|
// + transitions + lightning flash.
|
||||||
var cal = WorldTime.CurrentCalendar;
|
var cal = WorldTime.CurrentCalendar;
|
||||||
|
|
@ -9303,6 +9327,29 @@ public sealed class GameWindow : IDisposable
|
||||||
// WalkEntitiesInto) treat null and an empty set identically, so an
|
// WalkEntitiesInto) treat null and an empty set identically, so an
|
||||||
// always-non-null (possibly empty) set is behaviorally the same as
|
// always-non-null (possibly empty) set is behaviorally the same as
|
||||||
// the old null-when-nothing-animated local.
|
// the old null-when-nothing-animated local.
|
||||||
|
//
|
||||||
|
// Every entity in _animatedEntities (i.e. every entity with a
|
||||||
|
// Sequencer) is added UNCONDITIONALLY: membership here is the
|
||||||
|
// "re-classify me every frame, don't use the Tier-1 static cache"
|
||||||
|
// flag, and the Tier-1 cache captures an entity's REST pose +
|
||||||
|
// opacity-1.0 exactly once. Any entity whose rendered state can
|
||||||
|
// depart that rest — a door held at its final OPEN frame, a wall
|
||||||
|
// held FADED-OUT by a TransparentPartHook — MUST stay on the
|
||||||
|
// per-frame path so the live sequencer pose + TranslucencyFadeManager
|
||||||
|
// opacity are read; otherwise it flips back to the stale cached
|
||||||
|
// closed/opaque state the instant its animation settles.
|
||||||
|
//
|
||||||
|
// DO NOT re-narrow this to "only if the current cycle is multi-frame"
|
||||||
|
// (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled
|
||||||
|
// one-shot hold IS pose-stable frame-to-frame, but stable at the
|
||||||
|
// HELD pose the cache does NOT contain — that is the door/fade
|
||||||
|
// flip-back. The per-frame re-classification cost that narrowing
|
||||||
|
// saved was a DEBUG-build artifact; Release is GPU-bound (the CPU
|
||||||
|
// work hides under GPU time), so the unconditional add is free where
|
||||||
|
// it matters. If a real Release CPU cost from static-prop
|
||||||
|
// re-classification is ever measured, gate on "entity is at its
|
||||||
|
// captured rest state" (default motion AND no active fade), never on
|
||||||
|
// "is mid-cycle".
|
||||||
_animatedIdsScratch.Clear();
|
_animatedIdsScratch.Clear();
|
||||||
foreach (var k in _animatedEntities.Keys)
|
foreach (var k in _animatedEntities.Keys)
|
||||||
_animatedIdsScratch.Add(k);
|
_animatedIdsScratch.Add(k);
|
||||||
|
|
@ -10440,6 +10487,12 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
|
||||||
|
// narrowing that dropped settled-open doors / faded-out walls back onto the
|
||||||
|
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the
|
||||||
|
// animatedIds build site — every Sequencer entity is now added
|
||||||
|
// unconditionally, which is the known-good pre-optimization behavior.
|
||||||
|
|
||||||
// R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
|
// R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
|
||||||
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel
|
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel
|
||||||
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
|
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
|
||||||
|
|
@ -11650,7 +11703,10 @@ public sealed class GameWindow : IDisposable
|
||||||
_worldState.TryGetLandblock(id, out var lb))
|
_worldState.TryGetLandblock(id, out var lb))
|
||||||
{
|
{
|
||||||
foreach (var ent in lb!.Entities)
|
foreach (var ent in lb!.Entities)
|
||||||
|
{
|
||||||
_lightingSink.UnregisterOwner(ent.Id);
|
_lightingSink.UnregisterOwner(ent.Id);
|
||||||
|
_translucencyFades.ClearEntity(ent.Id); // #188
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_terrain?.RemoveLandblock(id);
|
_terrain?.RemoveLandblock(id);
|
||||||
_physicsEngine.RemoveLandblock(id);
|
_physicsEngine.RemoveLandblock(id);
|
||||||
|
|
|
||||||
|
|
@ -180,10 +180,13 @@ public static class RenderBootstrap
|
||||||
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
|
// --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) ---
|
||||||
var classificationCache = new Wb.EntityClassificationCache();
|
var classificationCache = new Wb.EntityClassificationCache();
|
||||||
|
|
||||||
|
// --- TranslucencyFadeManager (GameWindow — field initializer, new()) ---
|
||||||
|
var translucencyFades = new AcDream.Core.Rendering.TranslucencyFadeManager();
|
||||||
|
|
||||||
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
// --- WbDrawDispatcher (GameWindow ~2377-2381) ---
|
||||||
var drawDispatcher = new Wb.WbDrawDispatcher(
|
var drawDispatcher = new Wb.WbDrawDispatcher(
|
||||||
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter,
|
||||||
bindless, classificationCache);
|
bindless, classificationCache, translucencyFades);
|
||||||
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage;
|
||||||
|
|
||||||
// --- Vitals dat font (GameWindow ~1820-1822) ---
|
// --- Vitals dat font (GameWindow ~1820-1822) ---
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ in vec3 vWorldPos;
|
||||||
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert
|
||||||
in flat uvec2 vTextureHandle;
|
in flat uvec2 vTextureHandle;
|
||||||
in flat uint vTextureLayer;
|
in flat uint vTextureLayer;
|
||||||
|
in flat float vOpacityMultiplier; // #188
|
||||||
|
|
||||||
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
|
// uRenderPass values (Phase N.5 Decision 2 — two-pass alpha-test):
|
||||||
// 0 = opaque pass — discard fragments with alpha < 0.95
|
// 0 = opaque pass — discard fragments with alpha < 0.95
|
||||||
|
|
@ -102,5 +103,9 @@ void main() {
|
||||||
|
|
||||||
vec3 rgb = color.rgb * lit;
|
vec3 rgb = color.rgb * lit;
|
||||||
rgb = applyFog(rgb, vWorldPos);
|
rgb = applyFog(rgb, vWorldPos);
|
||||||
FragColor = vec4(rgb, color.a);
|
// #188: multiply the FINAL alpha only — the discard thresholds above stay
|
||||||
|
// keyed on the raw sampled color.a, so the last few frames of a fade
|
||||||
|
// (multiplier crossing under 0.05) still ramp smoothly toward zero rather
|
||||||
|
// than popping invisible early against the discard cutoff.
|
||||||
|
FragColor = vec4(rgb, color.a * vOpacityMultiplier);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,16 @@ layout(std430, binding = 6) readonly buffer InstanceIndoorBuf {
|
||||||
uint instanceIndoor[];
|
uint instanceIndoor[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #188: per-instance opacity multiplier, 1 per instance, parallel to the
|
||||||
|
// binding=0 instance buffer (same instanceIndex). 1.0 = unmodified; <1.0
|
||||||
|
// while a TransparentPartHook translucency fade is in flight for the
|
||||||
|
// entity/part this instance belongs to (e.g. the "fading wall" secret-
|
||||||
|
// passage doors). Multiplied against the sampled texture alpha in
|
||||||
|
// mesh_modern.frag.
|
||||||
|
layout(std430, binding = 7) readonly buffer InstanceAlphaBuf {
|
||||||
|
float instanceAlpha[];
|
||||||
|
};
|
||||||
|
|
||||||
// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
|
// 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
|
// 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
|
// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
|
||||||
|
|
@ -266,10 +276,12 @@ out vec3 vWorldPos;
|
||||||
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights)
|
||||||
out flat uvec2 vTextureHandle;
|
out flat uvec2 vTextureHandle;
|
||||||
out flat uint vTextureLayer;
|
out flat uint vTextureLayer;
|
||||||
|
out flat float vOpacityMultiplier; // #188
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
|
int instanceIndex = gl_BaseInstanceARB + gl_InstanceID;
|
||||||
mat4 model = Instances[instanceIndex].transform;
|
mat4 model = Instances[instanceIndex].transform;
|
||||||
|
vOpacityMultiplier = instanceAlpha[instanceIndex]; // #188
|
||||||
|
|
||||||
vec4 worldPos = model * vec4(aPosition, 1.0);
|
vec4 worldPos = model * vec4(aPosition, 1.0);
|
||||||
gl_Position = uViewProjection * worldPos;
|
gl_Position = uViewProjection * worldPos;
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
// miss-populate / hit-fast-path through the loop.
|
// miss-populate / hit-fast-path through the loop.
|
||||||
private readonly EntityClassificationCache _cache;
|
private readonly EntityClassificationCache _cache;
|
||||||
|
|
||||||
|
// #188 — per-(entity, Setup-part) translucency ramp state (fading doors /
|
||||||
|
// secret-passage walls). ClassifyBatches reads this per part to compute
|
||||||
|
// the instance's opacity multiplier; never mutated here.
|
||||||
|
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades;
|
||||||
|
|
||||||
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
|
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
|
||||||
// entity through the slow path. Read once in ctor.
|
// entity through the slow path. Read once in ctor.
|
||||||
private readonly bool _tier1CacheDisabled =
|
private readonly bool _tier1CacheDisabled =
|
||||||
|
|
@ -150,6 +155,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
// Mechanically a clone of _clipSlotData / _clipSlotSsbo.
|
// Mechanically a clone of _clipSlotData / _clipSlotSsbo.
|
||||||
private uint _instIndoorSsbo;
|
private uint _instIndoorSsbo;
|
||||||
private uint[] _indoorData = new uint[256];
|
private uint[] _indoorData = new uint[256];
|
||||||
|
|
||||||
|
// #188: per-instance opacity multiplier (binding=7), one float per
|
||||||
|
// instance, parallel to _instanceSsbo. 1.0 = unmodified (the dat's own
|
||||||
|
// material/texture alpha, untouched); < 1.0 multiplies the shader's
|
||||||
|
// sampled alpha for an entity mid-TransparentPartHook fade. Mechanically
|
||||||
|
// a clone of _indoorData / _instIndoorSsbo, one binding higher.
|
||||||
|
private uint _instAlphaSsbo;
|
||||||
|
private float[] _alphaData = new float[256];
|
||||||
// This frame's point-light snapshot, handed in by GameWindow before Draw via
|
// This frame's point-light snapshot, handed in by GameWindow before Draw via
|
||||||
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
|
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
|
||||||
private IReadOnlyList<LightSource>? _pointSnapshot;
|
private IReadOnlyList<LightSource>? _pointSnapshot;
|
||||||
|
|
@ -340,7 +353,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
WbMeshAdapter meshAdapter,
|
WbMeshAdapter meshAdapter,
|
||||||
EntitySpawnAdapter entitySpawnAdapter,
|
EntitySpawnAdapter entitySpawnAdapter,
|
||||||
BindlessSupport bindless,
|
BindlessSupport bindless,
|
||||||
EntityClassificationCache classificationCache)
|
EntityClassificationCache classificationCache,
|
||||||
|
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(gl);
|
ArgumentNullException.ThrowIfNull(gl);
|
||||||
ArgumentNullException.ThrowIfNull(shader);
|
ArgumentNullException.ThrowIfNull(shader);
|
||||||
|
|
@ -348,6 +362,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
ArgumentNullException.ThrowIfNull(meshAdapter);
|
ArgumentNullException.ThrowIfNull(meshAdapter);
|
||||||
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
|
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
|
||||||
ArgumentNullException.ThrowIfNull(classificationCache);
|
ArgumentNullException.ThrowIfNull(classificationCache);
|
||||||
|
ArgumentNullException.ThrowIfNull(translucencyFades);
|
||||||
|
|
||||||
_gl = gl;
|
_gl = gl;
|
||||||
_shader = shader;
|
_shader = shader;
|
||||||
|
|
@ -355,6 +370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
_meshAdapter = meshAdapter;
|
_meshAdapter = meshAdapter;
|
||||||
_entitySpawnAdapter = entitySpawnAdapter;
|
_entitySpawnAdapter = entitySpawnAdapter;
|
||||||
_cache = classificationCache;
|
_cache = classificationCache;
|
||||||
|
_translucencyFades = translucencyFades;
|
||||||
|
|
||||||
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
||||||
_instanceSsbo = _gl.GenBuffer();
|
_instanceSsbo = _gl.GenBuffer();
|
||||||
|
|
@ -364,6 +380,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
|
_globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4
|
||||||
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
|
_instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5
|
||||||
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
|
_instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6
|
||||||
|
_instAlphaSsbo = _gl.GenBuffer(); // #188 binding=7
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -1290,8 +1307,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
bool drewAny = false;
|
bool drewAny = false;
|
||||||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
|
// #188: setupPartIndex is the SAME index space
|
||||||
|
// TransparentPartHook.PartIndex addresses — retail's CPartArray
|
||||||
|
// numbers parts by their ordinal position in the Setup's own
|
||||||
|
// part list (SetupPartTransforms.Compute is the other verified
|
||||||
|
// consumer of this exact indexing: one Matrix4x4 per
|
||||||
|
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
|
||||||
|
// MeshRef is acdream's own decomposition of top-level
|
||||||
|
// attachments (weapon/shield/etc), a different concept.
|
||||||
|
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
|
||||||
{
|
{
|
||||||
|
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
|
||||||
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
|
||||||
if (partData is null)
|
if (partData is null)
|
||||||
{
|
{
|
||||||
|
|
@ -1332,16 +1358,43 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
var restPose = partTransform * meshRef.PartTransform;
|
var restPose = partTransform * meshRef.PartTransform;
|
||||||
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
|
|
||||||
|
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
|
||||||
|
// part's translucency hits EXACTLY 1.0 (fully invisible), retail
|
||||||
|
// sets draw_state|=1 and skips the whole part outright — not a
|
||||||
|
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
|
||||||
|
// t=1 commits the bitwise-exact value so this check is safe.
|
||||||
|
float opacityMultiplier = 1.0f;
|
||||||
|
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
|
||||||
|
{
|
||||||
|
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
|
||||||
|
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, opacityMultiplier, collector);
|
||||||
drewAny = true;
|
drewAny = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
{
|
||||||
|
// #188: a bare (non-Setup) GfxObj entity has exactly one part —
|
||||||
|
// retail's CPartArray for such an object is a single-entry array,
|
||||||
|
// so TransparentPartHook.PartIndex for it is always 0.
|
||||||
|
float opacityMultiplier = 1.0f;
|
||||||
|
bool fullyInvisible = false;
|
||||||
|
if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue))
|
||||||
|
{
|
||||||
|
if (translucencyValue >= 1.0f) fullyInvisible = true;
|
||||||
|
else opacityMultiplier = 1f - translucencyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fullyInvisible)
|
||||||
{
|
{
|
||||||
var model = meshRef.PartTransform * entityWorld;
|
var model = meshRef.PartTransform * entityWorld;
|
||||||
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
|
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector);
|
||||||
drewAny = true;
|
drewAny = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track THIS entity for the next iteration's flush check. Only
|
// Track THIS entity for the next iteration's flush check. Only
|
||||||
// when collector is non-null (entity is static); animated entities
|
// when collector is non-null (entity is static); animated entities
|
||||||
|
|
@ -1427,6 +1480,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
if (_indoorData.Length < totalInstances)
|
if (_indoorData.Length < totalInstances)
|
||||||
_indoorData = new uint[totalInstances + 256];
|
_indoorData = new uint[totalInstances + 256];
|
||||||
|
|
||||||
|
// #188: per-instance opacity buffer, one float per instance, parallel to
|
||||||
|
// _clipSlotData / _instanceData. Grown on demand like the others.
|
||||||
|
if (_alphaData.Length < totalInstances)
|
||||||
|
_alphaData = new float[totalInstances + 256];
|
||||||
|
|
||||||
_opaqueDraws.Clear();
|
_opaqueDraws.Clear();
|
||||||
_translucentDraws.Clear();
|
_translucentDraws.Clear();
|
||||||
|
|
||||||
|
|
@ -1462,6 +1520,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
|
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
|
||||||
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
|
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
|
||||||
_indoorData[cursor] = grp.IndoorFlags[i];
|
_indoorData[cursor] = grp.IndoorFlags[i];
|
||||||
|
// #188: Opacities[] is parallel to Matrices[]; write at the same
|
||||||
|
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
|
||||||
|
_alphaData[cursor] = grp.Opacities[i];
|
||||||
cursor++;
|
cursor++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1553,6 +1614,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
fixed (uint* dp = _indoorData)
|
fixed (uint* dp = _indoorData)
|
||||||
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint));
|
UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint));
|
||||||
|
|
||||||
|
// #188: per-instance opacity buffer (binding=7), one float per instance,
|
||||||
|
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
|
||||||
|
// is uploaded — stale tail never read (same guarantee as clip-slot above).
|
||||||
|
fixed (float* ap = _alphaData)
|
||||||
|
UploadSsbo(_instAlphaSsbo, 7, ap, totalInstances * sizeof(float));
|
||||||
|
|
||||||
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
||||||
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
|
// 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
|
// per-instance buffer holds 8 int indices into it per instance, laid out
|
||||||
|
|
@ -2056,6 +2123,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
grp.Matrices.Add(model);
|
grp.Matrices.Add(model);
|
||||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||||
|
// #188: cache-hit entities are always non-animated (the Tier-1 cache
|
||||||
|
// gates on !isAnimated), and TranslucencyFadeManager only ever holds
|
||||||
|
// state for entities whose animation hooks fired — so a cached
|
||||||
|
// instance can never be mid-fade. Always unmodified opacity.
|
||||||
|
grp.Opacities.Add(1.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -2181,6 +2253,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
ulong palHash,
|
ulong palHash,
|
||||||
AcSurfaceMetadataTable metaTable,
|
AcSurfaceMetadataTable metaTable,
|
||||||
Matrix4x4 restPose,
|
Matrix4x4 restPose,
|
||||||
|
float opacityMultiplier = 1.0f,
|
||||||
List<CachedBatch>? collector = null)
|
List<CachedBatch>? collector = null)
|
||||||
{
|
{
|
||||||
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
|
||||||
|
|
@ -2199,6 +2272,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
: TranslucencyKind.Opaque;
|
: TranslucencyKind.Opaque;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
|
||||||
|
// must route through the alpha-blend pass so mesh_modern.frag's
|
||||||
|
// (blend-enabled) shader actually composites the reduced alpha —
|
||||||
|
// the no-blend opaque pass would ignore it.
|
||||||
|
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
|
||||||
|
translucency = TranslucencyKind.AlphaBlend;
|
||||||
|
|
||||||
ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
|
ulong texHandle = ResolveTexture(entity, meshRef, batch, palHash);
|
||||||
if (texHandle == 0) continue;
|
if (texHandle == 0) continue;
|
||||||
|
|
||||||
|
|
@ -2228,6 +2308,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
grp.Matrices.Add(model);
|
grp.Matrices.Add(model);
|
||||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||||
|
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
|
||||||
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
collector?.Add(new CachedBatch(key, texHandle, restPose));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2310,6 +2391,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
|
if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4
|
||||||
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
|
if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5
|
||||||
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
|
if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6
|
||||||
|
if (_instAlphaSsbo != 0) _gl.DeleteBuffer(_instAlphaSsbo); // #188 binding=7
|
||||||
if (_gpuQueriesInitialized)
|
if (_gpuQueriesInitialized)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < GpuQueryRingDepth; i++)
|
for (int i = 0; i < GpuQueryRingDepth; i++)
|
||||||
|
|
@ -2509,5 +2591,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
||||||
// for outdoor objects (gets the sun). Written into _indoorData at the same
|
// for outdoor objects (gets the sun). Written into _indoorData at the same
|
||||||
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
|
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
|
||||||
public readonly List<uint> IndoorFlags = new();
|
public readonly List<uint> IndoorFlags = new();
|
||||||
|
|
||||||
|
// #188: per-instance opacity multiplier, parallel to Matrices.
|
||||||
|
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
|
||||||
|
// fade is in flight for the instance whose matrix is Matrices[i]. At
|
||||||
|
// layout time the dispatcher writes Opacities[i] into _alphaData at
|
||||||
|
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
|
||||||
|
public readonly List<float> Opacities = new();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,14 @@ namespace AcDream.Core.Physics;
|
||||||
/// (animation-hook frame = damage frame for melee / thrown).
|
/// (animation-hook frame = damage frame for melee / thrown).
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
|
/// <see cref="TransparentPartHook"/> → #188
|
||||||
|
/// <c>AcDream.Core.Rendering.TranslucencyHookSink</c> (per-Setup-part
|
||||||
|
/// translucency ramp; see <c>TranslucencyFadeManager</c>).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
/// <see cref="ReplaceObjectHook"/>,
|
/// <see cref="ReplaceObjectHook"/>,
|
||||||
/// <see cref="TransparentHook"/>,
|
/// <see cref="TransparentHook"/> (whole-object variant — NOT yet
|
||||||
|
/// ported, #188 scope was the per-part case only),
|
||||||
/// <see cref="LuminousHook"/>,
|
/// <see cref="LuminousHook"/>,
|
||||||
/// <see cref="DiffuseHook"/>,
|
/// <see cref="DiffuseHook"/>,
|
||||||
/// <see cref="ScaleHook"/>,
|
/// <see cref="ScaleHook"/>,
|
||||||
|
|
@ -43,7 +49,9 @@ namespace AcDream.Core.Physics;
|
||||||
/// <see cref="SetOmegaHook"/>,
|
/// <see cref="SetOmegaHook"/>,
|
||||||
/// <see cref="TextureVelocityHook"/>,
|
/// <see cref="TextureVelocityHook"/>,
|
||||||
/// <see cref="SetLightHook"/> →
|
/// <see cref="SetLightHook"/> →
|
||||||
/// GfxObjMesh / renderer state mutations on the target entity.
|
/// GfxObjMesh / renderer state mutations on the target entity
|
||||||
|
/// (<see cref="SetLightHook"/> is the one exception already wired,
|
||||||
|
/// via <c>AcDream.Core.Lighting.LightingHookSink</c>).
|
||||||
/// </description></item>
|
/// </description></item>
|
||||||
/// <item><description>
|
/// <item><description>
|
||||||
/// <see cref="AnimationDoneHook"/> → UI / controller notifications
|
/// <see cref="AnimationDoneHook"/> → UI / controller notifications
|
||||||
|
|
|
||||||
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
167
src/AcDream.Core/Rendering/TranslucencyFadeManager.cs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #188 — per-(entity, Setup-part) translucency ramp state. Ports retail's
|
||||||
|
/// FPHook TRANSLUCENCY / PART_TRANSLUCENCY interpolation
|
||||||
|
/// (<c>CPhysicsObj::SetPartTranslucency</c> 0x00511730,
|
||||||
|
/// <c>FPHook::Execute</c> 0x0051baa0, <c>CPhysicsObj::process_fp_hook</c>
|
||||||
|
/// 0x005135c0): a linear ramp from <c>Start</c> to <c>End</c> over
|
||||||
|
/// <c>Time</c> seconds, ticked every frame rather than retail's per-object
|
||||||
|
/// physics tick (behaviorally identical — both re-evaluate the same
|
||||||
|
/// elapsed/duration ratio every simulated step).
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail's translucency scale is the OPPOSITE of "opacity": 0 = fully
|
||||||
|
/// solid/opaque, 1 = fully invisible (<c>CMaterial::SetTranslucencySimple</c>
|
||||||
|
/// 0x005396f0: <c>alpha = 1 - translucency</c>). Values here are stored on
|
||||||
|
/// that same [0,1] translucency scale — callers convert to an alpha/opacity
|
||||||
|
/// multiplier at the point of use.
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Retail's instant-apply shortcut: if the hook's <c>Time</c> is at or below
|
||||||
|
/// ~0.2 ms, the target value is applied immediately with no ramp
|
||||||
|
/// (<c>SetPartTranslucency</c>'s epsilon check). <see cref="TranslucencyInstantEpsilon"/>
|
||||||
|
/// is that exact retail constant.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TranslucencyFadeManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's exact epsilon (<c>CPhysicsObj::SetPartTranslucency</c>
|
||||||
|
/// 0x00511730): a hook <c>Time</c> at or below this is applied
|
||||||
|
/// instantly, no ramp.
|
||||||
|
/// </summary>
|
||||||
|
public const float TranslucencyInstantEpsilon = 0.000199999995f;
|
||||||
|
|
||||||
|
private sealed class Fade
|
||||||
|
{
|
||||||
|
public float Elapsed;
|
||||||
|
public float Duration;
|
||||||
|
public float Start;
|
||||||
|
public float End;
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityId -> partIndex -> in-flight ramp.
|
||||||
|
private readonly Dictionary<uint, Dictionary<uint, Fade>> _activeFades = new();
|
||||||
|
|
||||||
|
// entityId -> partIndex -> current committed translucency value
|
||||||
|
// ([0,1], retail scale). Persists after a fade completes so later
|
||||||
|
// frames keep reading the settled value.
|
||||||
|
private readonly Dictionary<uint, Dictionary<uint, float>> _committed = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Start (or replace) a translucency ramp for one Setup part of one
|
||||||
|
/// entity. Mirrors <c>CPhysicsObj::SetPartTranslucency</c>: a
|
||||||
|
/// near-zero <paramref name="time"/> applies <paramref name="end"/>
|
||||||
|
/// immediately; otherwise the value ramps from
|
||||||
|
/// <paramref name="start"/> to <paramref name="end"/> across
|
||||||
|
/// <paramref name="time"/> seconds, advanced by <see cref="AdvanceAll"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void StartPartFade(uint entityId, uint partIndex, float start, float end, float time)
|
||||||
|
{
|
||||||
|
if (time <= TranslucencyInstantEpsilon)
|
||||||
|
{
|
||||||
|
if (_activeFades.TryGetValue(entityId, out var activeForEntity))
|
||||||
|
activeForEntity.Remove(partIndex);
|
||||||
|
Commit(entityId, partIndex, end);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_activeFades.TryGetValue(entityId, out var fades))
|
||||||
|
{
|
||||||
|
fades = new Dictionary<uint, Fade>();
|
||||||
|
_activeFades[entityId] = fades;
|
||||||
|
}
|
||||||
|
fades[partIndex] = new Fade { Elapsed = 0f, Duration = time, Start = start, End = end };
|
||||||
|
|
||||||
|
// FPHook's first Execute call (at elapsed=0) already reports
|
||||||
|
// value=start — commit it now so a reader on the same frame the
|
||||||
|
// hook fired sees the ramp's starting value, not the stale prior one.
|
||||||
|
Commit(entityId, partIndex, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advance every in-flight fade by <paramref name="dt"/> seconds
|
||||||
|
/// (plain linear interpolation, no easing — matches
|
||||||
|
/// <c>FPHook::Execute</c>). A fade that reaches its duration commits
|
||||||
|
/// the bitwise-exact <c>End</c> value and stops advancing (retail
|
||||||
|
/// deletes the FPHook at that point); the committed value stays
|
||||||
|
/// readable via <see cref="TryGetCurrentValue"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void AdvanceAll(float dt)
|
||||||
|
{
|
||||||
|
if (_activeFades.Count == 0) return;
|
||||||
|
|
||||||
|
List<uint>? emptyEntities = null;
|
||||||
|
foreach (var (entityId, fades) in _activeFades)
|
||||||
|
{
|
||||||
|
List<uint>? finished = null;
|
||||||
|
foreach (var (partIndex, fade) in fades)
|
||||||
|
{
|
||||||
|
fade.Elapsed += dt;
|
||||||
|
float t = fade.Duration <= TranslucencyInstantEpsilon
|
||||||
|
? 1f
|
||||||
|
: Math.Clamp(fade.Elapsed / fade.Duration, 0f, 1f);
|
||||||
|
|
||||||
|
float value = t >= 1f ? fade.End : fade.Start + (fade.End - fade.Start) * t;
|
||||||
|
Commit(entityId, partIndex, value);
|
||||||
|
|
||||||
|
if (t >= 1f)
|
||||||
|
{
|
||||||
|
finished ??= new List<uint>();
|
||||||
|
finished.Add(partIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finished is not null)
|
||||||
|
{
|
||||||
|
foreach (var partIndex in finished)
|
||||||
|
fades.Remove(partIndex);
|
||||||
|
if (fades.Count == 0)
|
||||||
|
{
|
||||||
|
emptyEntities ??= new List<uint>();
|
||||||
|
emptyEntities.Add(entityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyEntities is not null)
|
||||||
|
foreach (var entityId in emptyEntities)
|
||||||
|
_activeFades.Remove(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read the current committed translucency value ([0,1], retail
|
||||||
|
/// scale — 0 opaque, 1 invisible) for one entity part. Returns false
|
||||||
|
/// if this part has never had a fade start (caller should fall back
|
||||||
|
/// to the dat-authored default — a no-op).
|
||||||
|
/// </summary>
|
||||||
|
public bool TryGetCurrentValue(uint entityId, uint partIndex, out float value)
|
||||||
|
{
|
||||||
|
if (_committed.TryGetValue(entityId, out var parts) && parts.TryGetValue(partIndex, out value))
|
||||||
|
return true;
|
||||||
|
value = 0f;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Drop all fade state for an entity (despawn / unload).</summary>
|
||||||
|
public void ClearEntity(uint entityId)
|
||||||
|
{
|
||||||
|
_activeFades.Remove(entityId);
|
||||||
|
_committed.Remove(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Commit(uint entityId, uint partIndex, float value)
|
||||||
|
{
|
||||||
|
if (!_committed.TryGetValue(entityId, out var parts))
|
||||||
|
{
|
||||||
|
parts = new Dictionary<uint, float>();
|
||||||
|
_committed[entityId] = parts;
|
||||||
|
}
|
||||||
|
parts[partIndex] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
38
src/AcDream.Core/Rendering/TranslucencyHookSink.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
using System;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #188 — routes <see cref="TransparentPartHook"/> animation hooks to a
|
||||||
|
/// <see cref="TranslucencyFadeManager"/>. Retail's
|
||||||
|
/// <c>TransparentPartHook::Execute</c> (0x00526cc0) is a one-liner calling
|
||||||
|
/// <c>CPhysicsObj::SetPartTranslucency(obj, part, start, end, time)</c> —
|
||||||
|
/// this sink is that same one-line forward.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Whole-object <see cref="TransparentHook"/> and <see cref="EtherealHook"/>
|
||||||
|
/// are deliberately NOT handled here. Retail's <c>EtherealHook::Execute</c>
|
||||||
|
/// (0x00526bc0) touches only <c>CPhysicsObj::set_ethereal</c> — collision
|
||||||
|
/// state, zero rendering coupling — and acdream's collision-passthrough
|
||||||
|
/// already applies server-authoritative via the <c>SetState</c> wire
|
||||||
|
/// message, independent of this hook firing client-side.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class TranslucencyHookSink : IAnimationHookSink
|
||||||
|
{
|
||||||
|
private readonly TranslucencyFadeManager _fades;
|
||||||
|
|
||||||
|
public TranslucencyHookSink(TranslucencyFadeManager fades)
|
||||||
|
{
|
||||||
|
_fades = fades ?? throw new ArgumentNullException(nameof(fades));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||||
|
{
|
||||||
|
if (hook is not TransparentPartHook tph) return;
|
||||||
|
_fades.StartPartFade(entityId, tph.PartIndex, tph.Start, tph.End, tph.Time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,8 +48,10 @@ public class Issue188FadingDoorMotionTableInspectionTests
|
||||||
foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("Hook", StringComparison.OrdinalIgnoreCase)))
|
foreach (var t in asm.GetTypes().Where(t => t.Name.Contains("Hook", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
_out.WriteLine($"=== type {t.FullName} (base={t.BaseType?.Name}) ===");
|
_out.WriteLine($"=== type {t.FullName} (base={t.BaseType?.Name}) ===");
|
||||||
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||||
_out.WriteLine($" {p.PropertyType.Name} {p.Name}");
|
_out.WriteLine($" [prop] {p.PropertyType.Name} {p.Name}");
|
||||||
|
foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||||
|
_out.WriteLine($" [field] {f.FieldType.Name} {f.Name}");
|
||||||
}
|
}
|
||||||
|
|
||||||
void DumpMembers(Type t, string label)
|
void DumpMembers(Type t, string label)
|
||||||
|
|
@ -107,6 +109,7 @@ public class Issue188FadingDoorMotionTableInspectionTests
|
||||||
if (anim is null) { _out.WriteLine($" [{source}] anim 0x{animId:X8} NOT FOUND"); continue; }
|
if (anim is null) { _out.WriteLine($" [{source}] anim 0x{animId:X8} NOT FOUND"); continue; }
|
||||||
|
|
||||||
int frameCount = 0, hookCount = 0;
|
int frameCount = 0, hookCount = 0;
|
||||||
|
int frameIdx = 0;
|
||||||
foreach (var frame in GetFrames(anim))
|
foreach (var frame in GetFrames(anim))
|
||||||
{
|
{
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
@ -114,7 +117,14 @@ public class Issue188FadingDoorMotionTableInspectionTests
|
||||||
{
|
{
|
||||||
hookCount++;
|
hookCount++;
|
||||||
hookTypesSeen.Add(hook.GetType().Name);
|
hookTypesSeen.Add(hook.GetType().Name);
|
||||||
|
// Dump every field's actual VALUE for this specific hook instance
|
||||||
|
// (not just the type) -- what alpha/duration retail authored.
|
||||||
|
var fieldDump = string.Join(" ", hook.GetType()
|
||||||
|
.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
|
||||||
|
.Select(f => $"{f.Name}={f.GetValue(hook)}"));
|
||||||
|
_out.WriteLine($" frame[{frameIdx}] {hook.GetType().Name}: {fieldDump}");
|
||||||
}
|
}
|
||||||
|
frameIdx++;
|
||||||
}
|
}
|
||||||
_out.WriteLine($" [{source}] anim 0x{animId:X8}: frames={frameCount} hooks={hookCount}");
|
_out.WriteLine($" [{source}] anim 0x{animId:X8}: frames={frameCount} hooks={hookCount}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class TranslucencyFadeManagerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void StartPartFade_InstantEpsilon_CommitsEndImmediately()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f,
|
||||||
|
time: TranslucencyFadeManager.TranslucencyInstantEpsilon);
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
|
||||||
|
Assert.Equal(1f, value);
|
||||||
|
|
||||||
|
// No ramp survives — advancing further changes nothing.
|
||||||
|
mgr.AdvanceAll(10f);
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float after));
|
||||||
|
Assert.Equal(1f, after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StartPartFade_ZeroTime_IsTreatedAsInstant()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 5, partIndex: 2, start: 0f, end: 0.5f, time: 0f);
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(5, 2, out float value));
|
||||||
|
Assert.Equal(0.5f, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StartPartFade_RealDuration_ImmediatelyReadableAsStart()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.2f, end: 0.8f, time: 1f);
|
||||||
|
|
||||||
|
// Before any AdvanceAll — the ramp's t=0 value must already be readable
|
||||||
|
// (FPHook's first Execute call reports value=start on the same frame).
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
|
||||||
|
Assert.Equal(0.2f, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_LinearInterpolation_MidpointIsExact()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f);
|
||||||
|
|
||||||
|
mgr.AdvanceAll(0.5f);
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
|
||||||
|
Assert.Equal(0.5f, value, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_AsymmetricRange_LinearAtQuarterDuration()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.2f, end: 1.0f, time: 4f);
|
||||||
|
|
||||||
|
mgr.AdvanceAll(1f); // t = 0.25
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
|
||||||
|
Assert.Equal(0.2f + (1.0f - 0.2f) * 0.25f, value, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_Overshoot_CommitsExactEndValue()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 0.267f);
|
||||||
|
|
||||||
|
mgr.AdvanceAll(10f); // massive overshoot past duration
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
|
||||||
|
Assert.Equal(1f, value); // bitwise-exact, not approximately-equal
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdvanceAll_PastCompletion_IsIdempotent()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 0.1f);
|
||||||
|
|
||||||
|
mgr.AdvanceAll(1f);
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float first));
|
||||||
|
|
||||||
|
mgr.AdvanceAll(1f); // ramp already finished — a second advance must not change anything
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float second));
|
||||||
|
|
||||||
|
Assert.Equal(first, second);
|
||||||
|
Assert.Equal(1f, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearEntity_RemovesActiveAndCommittedState()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f);
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out _));
|
||||||
|
|
||||||
|
mgr.ClearEntity(1);
|
||||||
|
|
||||||
|
Assert.False(mgr.TryGetCurrentValue(1, 0, out _));
|
||||||
|
mgr.AdvanceAll(1f); // must not throw even though the entity is gone
|
||||||
|
Assert.False(mgr.TryGetCurrentValue(1, 0, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StartPartFade_SecondCallReplacesFirst()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 10f);
|
||||||
|
mgr.AdvanceAll(1f); // 10% into the first (abandoned) ramp
|
||||||
|
|
||||||
|
// Restart with a fresh, shorter ramp — the old target must be abandoned.
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0.9f, end: 0f, time: 1f);
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float atRestart));
|
||||||
|
Assert.Equal(0.9f, atRestart);
|
||||||
|
|
||||||
|
mgr.AdvanceAll(1f); // finishes the SECOND ramp, not the first
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
|
||||||
|
Assert.Equal(0f, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetCurrentValue_UnknownPart_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
Assert.False(mgr.TryGetCurrentValue(entityId: 999, partIndex: 0, out float value));
|
||||||
|
Assert.Equal(0f, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultiplePartsOnSameEntity_AdvanceIndependently()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 0, start: 0f, end: 1f, time: 1f);
|
||||||
|
mgr.StartPartFade(entityId: 1, partIndex: 1, start: 0f, end: 1f, time: 2f);
|
||||||
|
|
||||||
|
mgr.AdvanceAll(1f);
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float part0));
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 1, out float part1));
|
||||||
|
Assert.Equal(1f, part0); // part 0's 1s ramp is done
|
||||||
|
Assert.Equal(0.5f, part1, 5); // part 1's 2s ramp is halfway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Rendering;
|
||||||
|
|
||||||
|
public sealed class TranslucencyHookSinkTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TransparentPartHook_ForwardsFieldsVerbatimToManager()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
var sink = new TranslucencyHookSink(mgr);
|
||||||
|
|
||||||
|
var hook = new TransparentPartHook { PartIndex = 3, Start = 0.1f, End = 0.9f, Time = 0.5f };
|
||||||
|
sink.OnHook(entityId: 42, entityWorldPosition: Vector3.Zero, hook: hook);
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(42, 3, out float value));
|
||||||
|
Assert.Equal(0.1f, value); // t=0 value, readable immediately after the hook fires
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransparentPartHook_InstantTime_CommitsEndImmediately()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
var sink = new TranslucencyHookSink(mgr);
|
||||||
|
|
||||||
|
var hook = new TransparentPartHook { PartIndex = 0, Start = 0f, End = 1f, Time = 0f };
|
||||||
|
sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: hook);
|
||||||
|
|
||||||
|
Assert.True(mgr.TryGetCurrentValue(1, 0, out float value));
|
||||||
|
Assert.Equal(1f, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EtherealHook_IsIgnored()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
var sink = new TranslucencyHookSink(mgr);
|
||||||
|
|
||||||
|
sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: new EtherealHook { Ethereal = true });
|
||||||
|
|
||||||
|
Assert.False(mgr.TryGetCurrentValue(1, 0, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WholeObjectTransparentHook_IsIgnored()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
var sink = new TranslucencyHookSink(mgr);
|
||||||
|
|
||||||
|
// #188 scope: the whole-object variant is deliberately NOT ported this pass.
|
||||||
|
sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero,
|
||||||
|
hook: new TransparentHook { Start = 0f, End = 1f, Time = 1f });
|
||||||
|
|
||||||
|
Assert.False(mgr.TryGetCurrentValue(1, 0, out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnrelatedHook_IsIgnored()
|
||||||
|
{
|
||||||
|
var mgr = new TranslucencyFadeManager();
|
||||||
|
var sink = new TranslucencyHookSink(mgr);
|
||||||
|
|
||||||
|
sink.OnHook(entityId: 1, entityWorldPosition: Vector3.Zero, hook: new SoundTableHook());
|
||||||
|
|
||||||
|
Assert.False(mgr.TryGetCurrentValue(1, 0, out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue