acdream/src/AcDream.App/Rendering/RetailChaseCamera.cs
Erik 48aaab811c fix #180: port retail's stateful camera sought-position - the sweep target converges onto the wall contact
The camera-collision sweep strobed the eye 0.27m every ~5-10 frames while
the compressed chase boom moved along corridor walls (pulledIn 0.27<->0.53
on ~1.4mm input drift): RetailChaseCamera re-demanded the FULL-length ideal
boom from scratch each frame, so the pivot->eye ray re-rolled the same
knife-edge r+-eps graze on the double-faced slabs every frame, and its two
first-contact solutions tear-interleaved at ~1700fps into the #176
"stripes/triangles".

Retail never re-rolls that ray. CameraManager::UpdateCamera (0x00456660)
interpolates FROM THE CURRENT SWEPT VIEWER toward the desired pose
(interpolate_origin/rotation, stiffness 0.45 x dt x 10, clamped) and the
result becomes viewer_sought_position (SmartBox::PlayerPhysicsUpdatedCallback
0x00452d60); update_viewer (0x00453ce0) sweeps pivot->SOUGHT. Pressed against
a wall the sweep ray extends one interpolation step past the contact
(sub-mm at high fps), so a bistable graze can move the eye by at most that
step - the strobe is structurally impossible. A 0.4mm/2e-4 dead-band parks
the sought exactly on the viewer when converged (0x00456fcd-0x00457035).

- RetailChaseCamera: _dampedEye -> _soughtEye + _publishedEye (retail's two
  Positions); lerp base = the published (swept) viewer; sweep targets the
  sought; total-fallback (ViewerCellId==0) resets the sought like
  set_viewer(player_pos, 1). The old "collision must NOT feed back into the
  damped state" comment had the coupling backwards - what stays clean is the
  transient desired pose, not the sought.
- SweepEye untouched (faithful update_viewer port, exonerated by the #180
  investigation).
- Tests: the old pin asserting instant full re-extension after a clamp
  (the divergence itself) replaced with four retail pins: gradual
  re-extension, sweep-target-converges-onto-contact, total-fallback
  re-extends from the player, wall-press glide stability.
- Pseudocode doc: docs/research/2026-07-06-camera-sought-position-pseudocode.md
  (UpdateCamera tail incl. the sought derivation + set_viewer reset semantics
  + Frame interpolate/close_rotation).
- Register: AD-37 (forward-vector nlerp vs quaternion slerp), AD-38
  (init-at-full-extension vs retail re-extend-from-player) - both
  pre-existing, identified during the decomp reading.

Suites green (Core 2599+2skip / App 729+2skip / UI 425 / Net 385).
Pending: autonomous visual verify + user gate (#180 + the #176 re-gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:49:01 +02:00

501 lines
25 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
/// <summary>
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// a STATEFUL sought position that converges from the current swept
/// viewer toward the desired boom pose (<c>CameraManager::UpdateCamera</c>
/// 0x00456660 → <c>viewer_sought_position</c>, the #180 fix), 5-frame
/// velocity-averaged slope-aligned heading frame, mouse-input low-pass
/// filter. Pseudocode:
/// <c>docs/research/2026-07-06-camera-sought-position-pseudocode.md</c>.
///
/// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
/// next to the legacy <see cref="ChaseCamera"/>; both update every
/// frame so toggling the flag swaps cameras instantly. Visible behavior
/// vs legacy: lag-then-catch-up on turn/stop, tilt-with-terrain on
/// hills, jump-feedback without the legacy <c>_trackedZ</c> hack.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
/// </para>
/// </summary>
public sealed class RetailChaseCamera : ICamera
{
// ICamera surface.
public Vector3 Position { get; private set; }
/// <summary>
/// The cell the collided viewer-sphere ended in (retail <c>viewer_cell =
/// sphere_path.curr_cell</c>). Roots the render mode + indoor visibility + the portal
/// side-test in <see cref="GameWindow"/> (Phase W single-viewpoint V1) — the ONE viewpoint.
/// Equals the passed player cell when camera collision is off / the probe is null.
/// </summary>
public uint ViewerCellId { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
// Near plane = retail Render::znear = 0.1 m (decomp :342130/:342173/:1101867 —
// Render::SetFOVRad sets 0.1 flat; the legacy set_vdst variant is max(0.1, vdst·0.25)).
// MUST be smaller than the 0.3 m camera-collision sphere (PhysicsCameraCollisionProbe.
// ViewerSphereRadius): with a 1.0 m near, a wall the collided eye sits 0.3 m from
// falls INSIDE the near plane and is clipped away — pressing the camera into a corner
// let you see straight through the wall (§4 corner residual). History: 0.1 landed
// (137b4f2), was reverted (8bd3492) after correlating with missing indoor textures,
// and re-landed once #110 resolved: the textures were the pre-existing #105
// staged-texture-flush drop (WbMeshAdapter.Tick), and 0.1 merely raised its trigger
// probability by making more close-up geometry visible (more uploads in flight).
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 0.1f, 5000f);
// ── Public tunables (per-instance) ──────────────────────────────
/// <summary>Length of the viewer_offset vector. Retail default ≈ 2.61.</summary>
public float Distance { get; set; } = 2.61f;
/// <summary>Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).</summary>
public float Pitch { get; set; } = 0.291f;
/// <summary>
/// Yaw offset added on top of player yaw when slope-align is off
/// or velocity is too small to derive a heading. Used by hold-RMB
/// orbit to swing the camera around the player without rotating
/// the character.
/// </summary>
public float YawOffset { get; set; } = 0f;
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
public float PivotHeight { get; set; } = 1.5f;
/// <summary>
/// Optional spring-arm collision probe. When set (and
/// <see cref="CameraDiagnostics.CollideCamera"/> is true), the damped eye
/// is swept from the head-pivot and stopped at the first wall. Null leaves
/// the eye uncollided (the default for tests and the legacy path).
/// </summary>
public ICameraCollisionProbe? CollisionProbe { get; init; }
/// <summary>Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.</summary>
public float PlayerTranslucency { get; private set; }
/// <summary>Clamp bounds carried over from legacy ChaseCamera.</summary>
public const float DistanceMin = 2f;
public const float DistanceMax = 40f;
public const float PitchMin = -0.7f;
public const float PitchMax = 1.4f;
// Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp
// acclient_2013_pseudo_c.txt, 0x00456fcd0x00457035). SnapEpsilon = 2 ×
// 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail
// freezes the boom at an exact fixed point (0x00456fe1). RotCloseEpsilon =
// 0.000199999995 — the Frame::close_rotation tolerance (0x00456fdd). Without the
// snap, Vector3.Lerp asymptotes forever and the boom drifts at rest, walking the eye
// across a portal plane and flipping the viewer cell → the indoor flicker.
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
// ── Stateful camera state (retail SmartBox's two Positions) ─────
//
// _soughtEye = retail viewer_sought_position — the persisted sweep
// TARGET, re-derived each frame from the current swept
// viewer (NOT from itself).
// _publishedEye = retail viewer — the swept, published eye; the base
// of next frame's interpolation (SmartBox::
// PlayerPhysicsUpdatedCallback passes &this->viewer
// into UpdateCamera, 0x00452d75).
// _dampedForward = the sought's look direction. Sweeps translate but
// never rotate, so the viewer's rotation is always the
// previous sought rotation — one field serves both.
private readonly Vector3[] _velocityRing = new Vector3[5];
private int _velocityCount;
private Vector3 _soughtEye;
private Vector3 _publishedEye;
private Vector3 _dampedForward = new(1f, 0f, 0f);
private bool _initialised;
// Mouse-filter state — shared by FilterMouseDelta entrypoint.
private float _lastMouseDeltaX;
private float _lastMouseDeltaY;
private float _lastFilterTimeSec;
// ── Per-frame entry point ────────────────────────────────────────
/// <summary>
/// Advance the camera one frame. Caller passes the player's current
/// pose + velocity (in world space) + the frame's <c>dt</c> in
/// seconds. After this returns, <see cref="Position"/>,
/// <see cref="View"/>, and <see cref="PlayerTranslucency"/> reflect
/// the new state.
/// </summary>
public void Update(
Vector3 playerPosition,
float playerYaw,
Vector3 playerVelocity,
bool isOnGround,
Vector3 contactPlaneNormal,
float dt,
uint cellId = 0,
uint selfEntityId = 0)
{
// 1. Push velocity into 5-frame ring, get average.
PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount);
// 2. Heading vector — player's facing projected onto the contact
// plane (grounded) or world XY (airborne). See ComputeHeading
// doc + retail decomp :95644-95795 for why this is facing-based
// rather than velocity-based.
Vector3 heading = ComputeHeading(
avgVel,
playerYaw + YawOffset,
isOnGround,
contactPlaneNormal,
CameraDiagnostics.AlignToSlope);
// 3. Orthonormal heading-frame basis.
var (forward, _, up) = BuildBasis(heading);
// 4. Target pose.
Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight);
float horizontal = Distance * MathF.Cos(Pitch);
float vertical = Distance * MathF.Sin(Pitch);
// viewer_offset = -horizontal along forward + vertical along up.
Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
// desired pose and assigns the result to viewer_sought_position
// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the sweep
// target converges onto whatever the collision produced last frame
// and re-extends gradually. The full-length ideal boom is never swept
// directly. Pseudocode:
// docs/research/2026-07-06-camera-sought-position-pseudocode.md.
if (!_initialised)
{
_soughtEye = targetEye;
_publishedEye = targetEye; // start converged (AD-38: retail re-extends from the player)
_dampedForward = targetForward;
_initialised = true;
}
else
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
// interpolate_origin(viewer.frame → desired, t) — the lerp base is the
// VIEWER (0x00456fae), not the previous sought. The forward base is the
// viewer's rotation ≡ the previous sought forward (sweeps never rotate).
Vector3 candidateEye = Vector3.Lerp(_publishedEye, targetEye, tAlpha);
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
// Retail UpdateCamera dead-band (0x00456fcd0x00457035): once the step
// off the viewer is sub-epsilon in translation AND rotation, the sought
// parks EXACTLY ON the viewer — an exact fixed point instead of an
// asymptote. Kills the at-rest drift AND the residual micro-jitter when
// pressed against a wall. See ApplyConvergenceSnap + SnapEpsilon.
(_soughtEye, _dampedForward, _) =
ApplyConvergenceSnap(_publishedEye, _dampedForward, candidateEye, candidateForward);
}
// 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer
// (0x00453ce0) sweeps the viewer_sphere pivot → viewer_sought_position
// and publishes the swept result as the viewer (set_viewer(curr_pos, 0)
// — the sought is NOT reset on success). Pressed against a wall, the
// sweep ray extends only one interpolation step past the contact, so a
// knife-edge r±ε graze can move the eye by at most that step (sub-mm at
// high fps) instead of re-solving the full-length boom with its 0.27 m
// bistable contact pair — the #180 strobe fix.
Vector3 publishedEye = _soughtEye;
// The viewer cell defaults to the player cell (collision off / null probe); the sweep
// overwrites it with the swept cell (retail viewer_cell). Always set so GameWindow has a
// robust per-frame "which cell is the camera in?" answer.
ViewerCellId = cellId;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{
var swept = CollisionProbe.SweepEye(pivotWorld, _soughtEye, cellId, selfEntityId, playerPosition);
publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId;
// Total-failure fallback = retail set_viewer(player_pos, reset_sought=1)
// (update_viewer :92886 and the cell==0 bail :92775 — both surface here as
// ViewerCellId == 0): the sought resets to the returned position and
// re-extends from there.
if (swept.ViewerCellId == 0)
_soughtEye = swept.Eye;
}
// Retail viewer — the base of next frame's interpolation (step 5).
_publishedEye = publishedEye;
// 6. Publish renderer surface (from the collided eye; rotation stays the
// smoothly-damped look direction toward the pivot).
Position = publishedEye;
View = Matrix4x4.CreateLookAt(publishedEye, publishedEye + _dampedForward, new Vector3(0f, 0f, 1f));
// 7. Auto-fade translucency — uses the published (collided) eye so the
// player fades once the eye is pulled in close.
float d = Vector3.Distance(publishedEye, pivotWorld);
PlayerTranslucency = ComputeTranslucency(d);
}
/// <summary>
/// Adjust the camera distance (zoom) by a delta, clamped to
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
/// legacy <c>ChaseCamera.AdjustDistance</c>.
/// </summary>
public void AdjustDistance(float delta) =>
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
/// <summary>
/// Adjust the camera pitch by a delta (radians), clamped to
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
/// <c>ChaseCamera.AdjustPitch</c>.
/// </summary>
public void AdjustPitch(float delta) =>
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
/// <summary>
/// Public entry point for the mouse-input low-pass filter. Calls
/// <see cref="FilterMouseAxis"/> on each axis with shared state.
/// </summary>
public (float outX, float outY) FilterMouseDelta(float rawX, float rawY, float weight, float nowSec)
{
// X first — advances the shared timestamp.
float x = FilterMouseAxis(rawX, weight, nowSec,
ref _lastMouseDeltaX, ref _lastFilterTimeSec, CameraDiagnostics.MouseLowPassWindowSec);
// Y uses a throwaway timestamp so the within-window check still uses the original delta
// (X already advanced _lastFilterTimeSec to nowSec; if Y reused it, the within-window
// check would be 0 < windowSec which is always true — which is what we want here, since
// both axes are sampled simultaneously and should both blend.).
float yTimeShadow = _lastFilterTimeSec - 1f; // force within-window path for the Y axis
float y = FilterMouseAxis(rawY, weight, nowSec,
ref _lastMouseDeltaY, ref yTimeShadow, CameraDiagnostics.MouseLowPassWindowSec);
return (x, y);
}
// Math primitives — pure, internal-static for unit-testability.
/// <summary>
/// Pick the heading vector that drives the camera basis. Mirrors
/// retail's <c>CameraManager::UpdateCamera</c> ALIGN_WITH_PLANE
/// path (decomp <c>acclient_2013_pseudo_c.txt:95644-95795</c>):
/// <list type="number">
/// <item><description>Base heading is the player's facing
/// direction in world space — <c>(cos yaw, sin yaw, 0)</c>
/// — not the velocity vector. Velocity only gates whether
/// slope-alignment fires.</description></item>
/// <item><description>If <paramref name="alignToSlope"/> is off
/// OR the player's horizontal velocity is below epsilon (i.e.
/// stationary OR jumping straight up), return that base
/// heading unchanged. This is the bit that keeps the camera
/// from swinging vertically during a jump.</description></item>
/// <item><description>Otherwise project the base heading onto
/// the plane perpendicular to a surface normal:
/// <see cref="System.Numerics.Plane"/>'s <c>Normal</c> when
/// grounded (slope-aligned), world <c>(0, 0, 1)</c> when
/// airborne (which is a no-op since the base is already
/// horizontal).</description></item>
/// <item><description>Normalize. If the projection collapsed
/// (heading parallel to normal), fall back to the unprojected
/// base.</description></item>
/// </list>
/// </summary>
/// <param name="avgVelocity">5-frame averaged player velocity in world space.</param>
/// <param name="yaw">Player facing yaw + any orbit offset, radians.</param>
/// <param name="isOnGround">Player's <c>transient_state &amp; 1</c> — does <paramref name="contactPlaneNormal"/> describe a valid contact plane?</param>
/// <param name="contactPlaneNormal">Player's current contact plane normal in world space; ignored when <paramref name="isOnGround"/> is false.</param>
/// <param name="alignToSlope">User-tunable; when false skips the projection and returns the flat facing direction.</param>
internal static Vector3 ComputeHeading(
Vector3 avgVelocity,
float yaw,
bool isOnGround,
Vector3 contactPlaneNormal,
bool alignToSlope)
{
// Base heading: player's facing direction in world XY plane.
Vector3 baseHeading = new(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
if (!alignToSlope) return baseHeading;
// Slope-align gate: player must be moving in XY. Retail tests
// |vx| > 0.0002 AND |vy| > 0.0002 (decomp :95704, :95713). The
// horizontal-magnitude-squared form is a cleaner equivalent.
// Without this, the airborne path would still project against
// world up (no-op) which is fine — but the standing-jump case
// wants the historical `direction` fallback that retail uses.
float hMagSq = avgVelocity.X * avgVelocity.X + avgVelocity.Y * avgVelocity.Y;
if (hMagSq < 1e-4f) return baseHeading;
// Pick the projection plane normal:
// grounded → contact_plane.N (slope-aligned camera basis)
// airborne → world up (projection becomes a no-op because
// baseHeading is already in the XY plane — but
// keeping the code path uniform makes the airborne
// case impossible to swing vertically).
Vector3 normal;
if (isOnGround && contactPlaneNormal.LengthSquared() > 0.01f)
normal = Vector3.Normalize(contactPlaneNormal);
else
normal = new Vector3(0f, 0f, 1f);
// Project baseHeading onto plane perpendicular to normal:
// projected = forward - normal * dot(forward, normal)
// On flat ground this is a no-op (dot ≈ 0). On a slope the
// projected vector gains a Z component matching the slope angle,
// which tilts the camera basis with the terrain.
float dot = Vector3.Dot(baseHeading, normal);
Vector3 projected = baseHeading - normal * dot;
// Degenerate: facing nearly parallel to normal (rare — would
// require player rotated to face into the ground). Fall back to
// the unprojected base heading.
if (projected.LengthSquared() < 1e-4f) return baseHeading;
return Vector3.Normalize(projected);
}
/// <summary>
/// Build an orthonormal basis with <c>forward = heading</c>. World
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it
/// the right axis falls back to world <c>+X</c> so the cross
/// product doesn't collapse.
/// </summary>
internal static (Vector3 forward, Vector3 right, Vector3 up) BuildBasis(Vector3 heading)
{
Vector3 forward = Vector3.Normalize(heading);
Vector3 worldUp = new(0f, 0f, 1f);
Vector3 right;
if (MathF.Abs(forward.Z) > 0.99f)
{
// Near-vertical forward — use world +X as the secondary axis.
right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f)));
}
else
{
right = Vector3.Normalize(Vector3.Cross(forward, worldUp));
}
Vector3 up = Vector3.Cross(right, forward); // already unit (forward + right orthonormal)
return (forward, right, up);
}
/// <summary>
/// FIFO-push a velocity sample into the 5-entry ring. Returns the
/// updated ring (mutates the input array; the return is for fluent
/// usage in tests). <paramref name="count"/> grows from 0 toward 5
/// and stays at 5 once the ring is full.
/// </summary>
internal static Vector3[] PushVelocity(Vector3[] ring, ref int count, Vector3 sample)
{
if (ring.Length != 5)
throw new ArgumentException("velocity ring must have 5 entries", nameof(ring));
// Shift left by 1 (oldest is overwritten), append new sample at the tail.
for (int i = 0; i < 4; i++) ring[i] = ring[i + 1];
ring[4] = sample;
if (count < 5) count++;
return ring;
}
/// <summary>
/// Average the <paramref name="count"/> most-recent entries of the
/// ring (entries <c>[ring.Length-count .. ring.Length)</c>). Returns
/// <see cref="Vector3.Zero"/> when count is zero.
/// </summary>
internal static Vector3 AverageVelocity(Vector3[] ring, int count)
{
if (count == 0) return Vector3.Zero;
Vector3 sum = Vector3.Zero;
int start = ring.Length - count;
for (int i = start; i < ring.Length; i++) sum += ring[i];
return sum / count;
}
/// <summary>
/// Exponential-damping rate per frame.
/// <c>alpha = clamp(stiffness * dt * 10, 0, 1)</c>. At
/// <c>stiffness=0.45</c>, <c>dt=1/60</c> → <c>~0.075</c>
/// (~150 ms half-life). Matches retail's
/// <c>x_1 = stiffness * dt * 10</c> formulation.
/// </summary>
internal static float ComputeDampingAlpha(float stiffness, float dt)
{
float a = stiffness * dt * 10f;
if (a <= 0f) return 0f;
if (a >= 1f) return 1f;
return a;
}
/// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> dead-band (decomp 0x00456fcd0x00457035).
/// After the per-frame lerp, if the translation step from <paramref name="viewerEye"/>
/// (the interpolation base = the current swept viewer) to <paramref name="candidateEye"/>
/// is below <see cref="SnapEpsilon"/> AND the rotation step is below
/// <see cref="RotCloseEpsilon"/>, retail returns the VIEWER unchanged — the sought
/// parks exactly on it (<c>return viewer</c>, 0x00457025). Returns <c>frozen=true</c>
/// with the viewer state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the test),
/// so the boom keeps converging while the heading is still turning.
/// </summary>
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
Vector3 viewerEye, Vector3 viewerForward, Vector3 candidateEye, Vector3 candidateForward)
{
bool translationConverged = Vector3.Distance(candidateEye, viewerEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, viewerForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged)
return (viewerEye, viewerForward, true); // park: exact fixed point on the viewer
return (candidateEye, candidateForward, false);
}
/// <summary>
/// Low-pass filter for a single mouse axis. Mirrors retail's
/// <c>CameraSet::FilterMouseInput</c>: if last sample was within
/// <paramref name="windowSec"/>, blend output with the average of
/// (previous, raw); otherwise pass-through. Final output =
/// <c>raw * (1 - weight) + blended * weight</c>. Updates
/// <paramref name="lastDelta"/> and <paramref name="lastTimeSec"/>
/// to the new state.
/// </summary>
internal static float FilterMouseAxis(
float raw,
float weight,
float nowSec,
ref float lastDelta,
ref float lastTimeSec,
float windowSec)
{
float avg;
if (nowSec - lastTimeSec < windowSec)
avg = (lastDelta + raw) * 0.5f;
else
avg = raw;
float output = raw * (1f - weight) + avg * weight;
lastDelta = output;
lastTimeSec = nowSec;
return output;
}
/// <summary>
/// Player-mesh translucency as a function of camera-to-pivot
/// distance. <c>0</c> = fully opaque, <c>1</c> = fully transparent.
/// Opaque at and beyond 0.45 m; fully transparent at and within
/// 0.20 m; linear ramp between. Matches retail's <c>CameraSet::
/// UpdateCamera</c> distance check (decomp :9770397725).
/// </summary>
internal static float ComputeTranslucency(float distance)
{
const float Far = 0.45f;
const float Near = 0.20f;
if (distance >= Far) return 0f;
if (distance <= Near) return 1f;
// Linear: t = 1 - (Near - distance) / (Near - Far)
return 1f - (Near - distance) / (Near - Far);
}
}