fix(camera): port retail offset adjustment laws

This commit is contained in:
Erik 2026-08-31 05:42:23 +02:00
parent cb22691beb
commit 90eba0ec3c
7 changed files with 212 additions and 61 deletions

View file

@ -82,13 +82,13 @@ internal sealed class CameraFrameController : ICameraFramePhase
if (input.ZoomOut)
retail.AdjustDistance(+adjustment);
if (input.Raise)
retail.AdjustPitch(+adjustment * 0.02f);
retail.AdjustPitch(+adjustment);
if (input.Lower)
retail.AdjustPitch(-adjustment * 0.02f);
retail.AdjustPitch(-adjustment);
if (input.RotateLeft)
retail.YawOffset += adjustment * 0.02f;
retail.AdjustYaw(+adjustment);
if (input.RotateRight)
retail.YawOffset -= adjustment * 0.02f;
retail.AdjustYaw(-adjustment);
}
else
{

View file

@ -34,6 +34,18 @@ public sealed class RetailChaseCamera : ICamera
private const float RetailLookDownBack = 2f;
private const float RetailMapBack = 450f;
private const float RetailFirstPersonForward = 0.18f;
private const float RetailLeaveHeadBack = 0.6f;
private const float RetailLeaveHeadUp = 0.5f;
// CameraSet::{Farther,Closer,Raise,Lower,Rotate} constants. The named
// retail decomp carries CAMERA_MOUSELOOK_INC at 0x0079BC04 and the
// eight-degree offset angle initialised at 0x006EABF0/0x0083D034.
internal const float OffsetScalePerAdjustment = 0.200000003f;
internal const float OffsetAngleRadians = 8f * MathF.PI / 180f;
internal const float MinimumOffsetLength = 0.5f;
internal const float MaximumHorizontalComponent = 10f;
internal const float MaximumVerticalComponent = 450f;
internal const float MinimumVerticalComponent = -1.8f;
// ICamera surface.
public Vector3 Position { get; private set; }
@ -106,12 +118,6 @@ public sealed class RetailChaseCamera : ICamera
/// <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
@ -311,27 +317,80 @@ public sealed class RetailChaseCamera : ICamera
}
/// <summary>
/// Adjust the camera distance (zoom) by a delta, clamped to
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
/// legacy <c>ChaseCamera.AdjustDistance</c>.
/// Apply one retail camera-adjustment unit to the viewer offset.
/// <c>CameraSet::Farther</c>/<c>Closer</c> (0x00458890/0x004586D0)
/// scale all three offset components multiplicatively by
/// <c>1 + adjustment * 0.2</c>. Positive values move farther and
/// negative values move closer. Retail refuses the complete write when
/// any component or the 0.5 m near limit would be crossed; it does not
/// clamp an invented scalar distance.
/// </summary>
public void AdjustDistance(float delta)
public void AdjustDistance(float adjustment)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
if (!float.IsFinite(adjustment) || adjustment == 0f)
return;
// Farther's exact in-head branch replaces (0,+0.18,0) with
// (0,-0.6,+0.5) and returns without applying the ordinary scale.
if (_inHead)
{
_inHead = false;
SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp);
return;
}
float scale = 1f + adjustment * OffsetScalePerAdjustment;
if (!(scale > 0f) || !float.IsFinite(scale))
return;
float candidateDistance = Distance * scale;
if (adjustment < 0f && !(candidateDistance > MinimumOffsetLength))
return;
TryWriteViewerOffset(candidateDistance, Pitch, preserveHorizontalSign: false);
}
/// <summary>
/// Adjust the camera pitch by a delta (radians), clamped to
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
/// <c>ChaseCamera.AdjustPitch</c>.
/// Apply one retail camera-adjustment unit to the viewer-offset pitch.
/// <c>CameraSet::Raise</c>/<c>Lower</c> (0x00457980/0x00457CF0)
/// rotate the offset by eight degrees per unit while preserving its
/// length. A write that would cross the vertical axis or a component
/// limit is refused as a whole.
/// </summary>
public void AdjustPitch(float delta)
public void AdjustPitch(float adjustment)
{
ExitLookDownForAdjustment();
ExitInHeadForAdjustment();
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
if (!float.IsFinite(adjustment) || adjustment == 0f)
return;
if (_inHead)
{
_inHead = false;
SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp);
}
float candidatePitch = Pitch + adjustment * OffsetAngleRadians;
TryWriteViewerOffset(Distance, candidatePitch, preserveHorizontalSign: true);
}
/// <summary>
/// Rotate the viewer offset around local Z using retail's eight-degree
/// adjustment unit (<c>CameraSet::Rotate @ 0x00458310</c>).
/// </summary>
public void AdjustYaw(float adjustment)
{
ExitLookDownForAdjustment();
if (!float.IsFinite(adjustment) || adjustment == 0f)
return;
if (_inHead)
{
_inHead = false;
SetViewerOffset(RetailLeaveHeadBack, RetailLeaveHeadUp);
}
YawOffset += adjustment * OffsetAngleRadians;
}
public void SetRetailDefaultView()
@ -416,20 +475,52 @@ public sealed class RetailChaseCamera : ICamera
RestoreLookDownView();
}
private void ExitInHeadForAdjustment()
{
if (!_inHead)
return;
_inHead = false;
Distance = DistanceMin;
}
private void SetViewerOffset(float back, float up)
{
Distance = MathF.Sqrt(back * back + up * up);
Pitch = MathF.Atan2(up, back);
}
private bool TryWriteViewerOffset(
float candidateDistance,
float candidatePitch,
bool preserveHorizontalSign)
{
if (!float.IsFinite(candidateDistance)
|| !float.IsFinite(candidatePitch)
|| !(candidateDistance > 0f))
{
return false;
}
float currentHorizontal = Distance * MathF.Cos(Pitch);
float candidateHorizontal = candidateDistance * MathF.Cos(candidatePitch);
if (preserveHorizontalSign
&& ((currentHorizontal > 0f && candidateHorizontal < 0f)
|| (currentHorizontal < 0f && candidateHorizontal > 0f)))
{
return false;
}
// AC frame-local viewer_offset. Only magnitudes matter for Farther's
// X/Y limits, but including YawOffset is essential: at diagonal orbit
// angles retail can reach a longer boom before either component is 10.
float x = candidateHorizontal * MathF.Sin(YawOffset);
float y = -candidateHorizontal * MathF.Cos(YawOffset);
float z = candidateDistance * MathF.Sin(candidatePitch);
if (!(MathF.Abs(x) < MaximumHorizontalComponent)
|| !(MathF.Abs(y) < MaximumHorizontalComponent)
|| !(z < MaximumVerticalComponent)
|| !(z > MinimumVerticalComponent))
{
return false;
}
Distance = candidateDistance;
Pitch = candidatePitch;
return true;
}
/// <summary>
/// Public entry point for the mouse-input low-pass filter. Calls
/// <see cref="FilterMouseAxis"/> on each axis with shared state.