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>
501 lines
25 KiB
C#
501 lines
25 KiB
C#
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, 0x00456fcd–0x00457035). 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 (0x00456fcd–0x00457035): 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 & 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 0x00456fcd–0x00457035).
|
||
/// 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 :97703–97725).
|
||
/// </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);
|
||
}
|
||
}
|