Fix: the combat "freeze on a kill" — camera shake channel + drop the position hold

This is what the operator's report was actually describing, and it was never a CPU
hitch: median frame was 12.2 ms with a kill costing ~15 ms, wave respawn is not a
spike, and enemies carry no PhysicsCollider so corpses never blocked.

Two bugs in PrototypeCameraRig.LateUpdate, both measured with a STATIONARY player
so the ideal camera motion is exactly zero and everything observed is artifact:

1. Shake was integrated by the follow filter. The smoothing ran as
   Lerp(transform.position, desired, k), and transform.position already contained
   last frame's random shake — so the filter treated shake as real positional error
   and corrected only ~9 %/frame. Shake was never subtracted, just slowly lerped out
   while new shake piled on, so in sustained combat the camera random-walked around
   its framing and never settled. One kill left it 0.447 units off-frame and still
   0.199 off 24 frames later. The follow now smooths a _basePos that shake never
   touches; shake is applied only when writing the transform.

2. The position HOLD froze the follow for a fixed number of RENDER frames on every
   kill and heavy hit (2, or 7 on a finisher), making the freeze framerate-dependent
   — 49 ms at 144 fps, 233 ms at 30 fps — and it took its base from
   transform.position, permanently baking that frame's shake in as a 0.283-unit
   single-frame jump. A camera that stops while the world keeps moving reads as a
   hitch, not as crunch. Dropped; impact rides the FOV punch + shake alone
   (operator's call). HitStopMaxFrames / HitStopFreezeEnabled / FinisherHoldFrames
   retire with it, and the three saved feel profiles drop the dead keys.

Worth recording: dropping the hold ALONE measured slightly worse (max deviation
0.653 vs 0.447) — the hold had been partly masking the shake integration. Both
fixes together: deviation returns to 0.000 within a few frames of a kill, mean
deviation 0.038 across the window. The 0.428 peak is now the shake impulse itself,
a transient punch that immediately returns instead of a drift that lingers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 23:03:06 -07:00
parent e0c59ad663
commit 4bd00818e1
5 changed files with 27 additions and 24 deletions
@@ -39,14 +39,10 @@ namespace ProjectM.Client
public static float HitStopFovKickMin;
/// <summary>Max FOV kick (deg) on a heavy player-dealt hit (delta >= HitStopRefDamage).</summary>
public static float HitStopFovKickMax;
/// <summary>Reserved cap for the (deferred) true freeze-frame, in fixed frames.</summary>
public static int HitStopMaxFrames;
/// <summary>Damage delta that saturates the player-dealt punch to HitStopFovKickMax.</summary>
public static float HitStopRefDamage;
/// <summary>Tint for the (deferred) enemy material hit-flash — exposed now, wired in the ShaderGraph slice.</summary>
public static Color HitFlashColor;
/// <summary>Master gate for the (deferred) true freeze-frame hit-stop. FALSE for v1 (camera-punch only).</summary>
public static bool HitStopFreezeEnabled;
// ---- Feature 1/2: death camera punch ----
/// <summary>Camera shake on LOCAL player death (loudest event by design).</summary>
@@ -170,8 +166,6 @@ namespace ProjectM.Client
public static float MeleeArcIntensity;
/// <summary>Bubbles shed along a melee cleave (0 = off).</summary>
public static int MeleeArcBubbles;
/// <summary>Camera hold frames when the melee FINISHER lands (07-20 G5 impact ladder; light hits use HitStopMaxFrames).</summary>
public static int FinisherHoldFrames;
// ---- 07-21 enemy hit-reacts (G5; the Rukhanka-safe flinch that replaced the cut vibrate) ----
/// <summary>Seconds the light flinch pulse holds (0 = hit-reacts OFF).</summary>
@@ -252,10 +246,8 @@ namespace ProjectM.Client
HitStopDurationMs = 90f;
HitStopFovKickMin = 0.6f;
HitStopFovKickMax = 2.2f;
HitStopMaxFrames = 2; // C4: a 2-frame (~33ms) camera hold reads as crunch, not a lag-y stutter
HitStopRefDamage = 30f;
HitFlashColor = new Color(1f, 0.85f, 0.55f, 1f);
HitStopFreezeEnabled = true; // C4: enable the finisher hit-stop hold (presentation-only camera freeze, bounded below)
// Feature 1/2 death
PlayerDeathShake = 0.50f;
@@ -318,7 +310,6 @@ namespace ProjectM.Client
CombatIdleHoldSec = 4f; // 07-18: Menacing01 combat-idle hold after the last swing
MeleeArcIntensity = 0.8f; // 07-21 HEAVY LOCK: the weapon is the read, the arc recedes further
MeleeArcBubbles = 6;
FinisherHoldFrames = 7; // 07-21 HEAVY LOCK: a fatter finisher beat (~117ms, still under the 8-frame ladder cap)
HitReactSeconds = 0.3f; // 07-21: light flinch pulse (clip plays at 1.4x -> the peak lands inside it)
HitStaggerSeconds = 0.55f; // heavy stagger pulse
HitReactStaggerDamage = 50f; // finisher (63 seeded) staggers, light (42) flinches -- tracks B2 poise