Camera component for the racing gameplay. Computes a smooth top-down camera pose that frames player cars, handles zoom, FOV boosts (speed, drift, dash), heading smoothing (including snapping or fixed angle), lead offset and teleport compensation.
using Machines.Events;
using Machines.GameModes;
using Machines.Player;
using Machines.Race;
namespace Machines.Components;
/// <summary>
/// The main racing camera
/// </summary>
public sealed class GameplayCamera : CameraBehaviour, ICarTeleportListener
{
[Property, Group( "Smoothing" )]
public float LerpSpeed { get; set; } = 3f;
[Property, Group( "Smoothing" )]
public float ZoomLerpSpeed { get; set; } = 2f;
[Property, Group( "Smoothing" )]
public float HeadingLerpSpeed { get; set; } = 4f;
/// <summary>
/// Chord length for heading sampling; wider = smoother but cuts corners more.
/// </summary>
[Property, Group( "Smoothing" )]
public float HeadingChord { get; set; } = 150f;
/// <summary>
/// Distance ahead to sample heading so the camera anticipates corners.
/// </summary>
[Property, Group( "Smoothing" )]
public float HeadingLookAhead { get; set; } = 200f;
[Property, Group( "Smoothing" )]
public float HeadingSnapIncrement { get; set; } = 45f;
[Property, Group( "Smoothing" )]
public float MaxHeadingSpeed { get; set; } = 90f;
[Property, Group( "Smoothing" )]
public float HeadingSnapThreshold { get; set; } = 10f;
/// <summary>
/// Lerp speed for the lead offset (higher = snappier).
/// </summary>
[Property, Group( "Smoothing" )]
public float LeadLerpSpeed { get; set; } = 3f;
[Property, Group( "Zoom" )]
public float MinHeight { get; set; } = 400f;
[Property, Group( "Zoom" )]
public float MaxHeight { get; set; } = 2000f;
[Property, Group( "Framing" )]
public float Padding { get; set; } = 200f;
/// <summary>
/// FOV used to compute framing height only; not the render FOV (see <see cref="BaseFieldOfView"/>).
/// </summary>
[Property, Group( "Framing" )]
public float FramingFieldOfView { get; set; } = 60f;
[Property, Group( "Framing" )]
public float PitchAngle { get; set; } = 55f;
/// <summary>
/// Frame all players (including bots/remote) instead of local only.
/// </summary>
[Property, Group( "Framing" )]
public bool IncludeAllPlayers { get; set; } = false;
/// <summary>
/// Use a fixed world heading (<see cref="Angle"/>) instead of following the track.
/// </summary>
[Property, Group( "Framing" )]
public bool FixedAngle { get; set; } = false;
[Property, Group( "Framing" ), Range( 0f, 360f ), ShowIf( "FixedAngle", true )]
public float Angle { get; set; } = 90f;
/// <summary>
/// Units ahead of the player group the camera leads, along their facing.
/// </summary>
[Property, Group( "Framing" ), Range( 0f, 600f )]
public float LeadDistance { get; set; } = 200f;
/// <summary>
/// Additional lead added at full speed, on top of <see cref="LeadDistance"/>.
/// </summary>
[Property, Group( "Framing" ), Range( 0f, 300f )]
public float LeadSpeedBonus { get; set; } = 50f;
/// <summary>
/// Base render FOV at rest; speed and drift bonuses stack on top.
/// </summary>
[Property, Group( "FOV" )]
public float BaseFieldOfView { get; set; } = 60f;
[Property, Group( "FOV" )]
public float SpeedFovBoost { get; set; } = 5f;
[Property, Group( "FOV" )]
public float SpeedFovMaxSpeed { get; set; } = 800f;
[Property, Group( "FOV" )]
public float FovLerpSpeed { get; set; } = 3f;
/// <summary>
/// Instant FOV kick on dash, decaying back at <see cref="FovPunchDecay"/> rate.
/// </summary>
[Property, Group( "FOV" )]
public float DashFovPunch { get; set; } = 10f;
/// <summary>
/// Extra FOV while drifting.
/// </summary>
[Property, Group( "FOV" )]
public float DriftFovBoost { get; set; } = 6f;
/// <summary>
/// Extra FOV during the post-drift boost window.
/// </summary>
[Property, Group( "FOV" )]
public float DriftBoostFov { get; set; } = 8f;
/// <summary>
/// Rate at which the FOV punch rises to its peak (higher = snappier).
/// </summary>
[Property, Group( "FOV" )]
public float FovPunchAttack { get; set; } = 6f;
/// <summary>
/// Rate at which FOV punches decay back to rest (higher = snappier).
/// </summary>
[Property, Group( "FOV" )]
public float FovPunchDecay { get; set; } = 2f;
/// <summary>
/// Height multiplier while drifting; pulls in so drift feels faster.
/// </summary>
[Property, Group( "Zoom" ), Range( 0.7f, 1f )]
public float DriftZoomScale { get; set; } = 0.92f;
/// <summary>
/// Pitch offset while drifting (negative = flatter, more horizon).
/// </summary>
[Property, Group( "Framing" ), Range( -20f, 20f )]
public float DriftPitchOffset { get; set; } = -6f;
private Vector3 _currentPosition;
private float _currentHeight;
private float _currentBaseZ;
private Vector3 _currentHeading;
private Vector2 _lastRawCenter;
private Vector2 _travelVel;
private float _lastTrackDistance = -1f;
private bool _initialized;
private float _currentFovBoost;
private float _fovPunch;
private float _fovPunchTarget;
private float _currentPitchOffset;
private bool _wasDashing;
// Drives the camera while racing; other behaviours outrank it when they apply.
public override bool WantsControl
{
get
{
var mode = BaseGameMode.Current;
if ( !mode.IsValid() )
return false;
return mode.State is GameModeState.Countdown or GameModeState.Playing or GameModeState.GameOver;
}
}
public override void OnActivated( CameraPose from )
{
// Snap framing on first frame; the director cross-fades from `from`.
_initialized = false;
}
// A car teleported (e.g. through a portal)
public void OnCarTeleported( Car car, Vector3 deltaPosition, float deltaYaw )
{
if ( !_initialized )
return;
var flat = new Vector2( deltaPosition.x, deltaPosition.y );
_currentPosition += new Vector3( flat.x, flat.y, 0f );
_currentBaseZ += deltaPosition.z;
_lastRawCenter += flat;
_currentHeading = Rotation.FromYaw( deltaYaw ) * _currentHeading;
_travelVel = Vector2.Zero;
_lastTrackDistance = -1f;
}
public override CameraPose Evaluate( CameraPose current )
{
return ComputeFraming( out var pose ) ? pose : current;
}
/// <summary>
/// Smoothed gameplay pose for this frame; returns false when no players to frame.
/// </summary>
private bool ComputeFraming( out CameraPose pose )
{
pose = default;
var players = GatherPlayers();
if ( players.Count == 0 )
return false;
// Bounding box on XY plane, average Z, and average car facing.
var min = new Vector2( float.MaxValue, float.MaxValue );
var max = new Vector2( float.MinValue, float.MinValue );
float avgZ = 0f;
var forwardSum = Vector2.Zero;
foreach ( var (p, fwd) in players )
{
if ( p.x < min.x ) min.x = p.x;
if ( p.y < min.y ) min.y = p.y;
if ( p.x > max.x ) max.x = p.x;
if ( p.y > max.y ) max.y = p.y;
avgZ += p.z;
forwardSum += new Vector2( fwd.x, fwd.y );
}
avgZ /= players.Count;
var carsForward = forwardSum.LengthSquared > 0.0001f ? forwardSum.Normal : Vector2.Zero;
// Group center this frame, before any lead offset.
var rawCenter = (min + max) * 0.5f;
// Smoothed travel velocity (units/s) for lag compensation.
var instTravel = Time.Delta > 0f ? (rawCenter - _lastRawCenter) / Time.Delta : Vector2.Zero;
_lastRawCenter = rawCenter;
_travelVel = _initialized ? Vector2.Lerp( _travelVel, instTravel, Time.Delta * LeadLerpSpeed ) : Vector2.Zero;
// Lead offset ahead of cars, scaled up with forward speed.
var fwdSpeed = MathF.Max( Vector2.Dot( _travelVel, carsForward ), 0f );
var speedFrac = SpeedFovMaxSpeed > 0.001f ? MathX.Clamp( fwdSpeed / SpeedFovMaxSpeed, 0f, 1f ) : 0f;
var leadDist = LeadDistance + LeadSpeedBonus * speedFrac;
var lead = (leadDist > 0f && carsForward != Vector2.Zero) ? carsForward * leadDist : Vector2.Zero;
// Feed-forward to cancel smoothing lag; strip backward component so reversing doesn't flip framing.
var lagComp = LerpSpeed > 0.001f ? _travelVel / LerpSpeed : Vector2.Zero;
if ( carsForward != Vector2.Zero )
{
var behind = MathF.Min( Vector2.Dot( lagComp, carsForward ), 0f );
lagComp -= carsForward * behind;
}
var center = rawCenter + lead + lagComp;
// Height needed to fit all players (larger axis extent).
var extentX = (max.x - min.x) * 0.5f + Padding;
var extentY = (max.y - min.y) * 0.5f + Padding;
var extent = MathF.Max( extentX, extentY );
var halfFovRad = MathX.DegreeToRadian( FramingFieldOfView * 0.5f );
var targetHeight = (extent / MathF.Tan( halfFovRad )).Clamp( MinHeight, MaxHeight );
// Pull in and flatten pitch while drifting.
var localCar = Car.Local;
var localDrifting = localCar.IsValid() && localCar.Drift.IsValid() && localCar.Drift.IsDrifting;
if ( localDrifting )
targetHeight *= DriftZoomScale;
_currentPitchOffset = MathX.Lerp( _currentPitchOffset, localDrifting ? DriftPitchOffset : 0f, Time.Delta * ZoomLerpSpeed );
// Heading: fixed yaw or track-following.
Vector3 targetHeading;
if ( FixedAngle )
{
var rad = MathX.DegreeToRadian( Angle );
targetHeading = new Vector3( MathF.Cos( rad ), MathF.Sin( rad ), 0f );
}
else
{
targetHeading = SnapHeading( GetTrackHeading( new Vector3( center.x, center.y, 0f ) ) );
}
// First frame: snap.
if ( !_initialized )
{
_currentPosition = new Vector3( center.x, center.y, 0f );
_currentHeight = targetHeight;
_currentBaseZ = avgZ;
_currentHeading = targetHeading;
_initialized = true;
}
var dt = Time.Delta;
_currentPosition.x = MathX.Lerp( _currentPosition.x, center.x, dt * LerpSpeed );
_currentPosition.y = MathX.Lerp( _currentPosition.y, center.y, dt * LerpSpeed );
_currentHeight = MathX.Lerp( _currentHeight, targetHeight, dt * ZoomLerpSpeed );
_currentBaseZ = MathX.Lerp( _currentBaseZ, avgZ, dt * LerpSpeed );
if ( FixedAngle )
{
_currentHeading = targetHeading;
}
else
{
// Reject >120° flips (projection error); rate-limit the rest.
if ( Vector3.Dot( _currentHeading, targetHeading ) < -0.5f )
targetHeading = _currentHeading;
_currentHeading = ClampedAngularLerp( _currentHeading, targetHeading, dt * HeadingLerpSpeed, MaxHeadingSpeed * dt );
}
var groundCenter = new Vector3( _currentPosition.x, _currentPosition.y, _currentBaseZ );
pose = TopDown( groundCenter, _currentHeading, PitchAngle + _currentPitchOffset, _currentHeight, ComputeRenderFov() );
return true;
}
/// <summary>
/// Render FOV: base + speed/drift/boost bonuses + decaying dash punch.
/// </summary>
private float ComputeRenderFov()
{
var car = Car.Local;
var speed = car.IsValid() && car.Movement.IsValid() ? MathF.Abs( car.Movement.CurrentSpeed ) : 0f;
var drifting = car.IsValid() && car.Drift.IsValid() && car.Drift.IsDrifting;
var driftBoosting = car.IsValid() && car.Movement.IsValid() && car.Movement.IsDriftBoosting;
var targetBoost = (speed / SpeedFovMaxSpeed).Clamp( 0f, 1f ) * SpeedFovBoost;
if ( drifting )
targetBoost += DriftFovBoost;
// Post-drift boost: widen FOV during the boost window.
if ( driftBoosting )
targetBoost += DriftBoostFov;
_currentFovBoost = MathX.Lerp( _currentFovBoost, targetBoost, Time.Delta * FovLerpSpeed );
// One-shot punch on dash rising edge, eased not snapped.
var dashing = car.IsValid() && car.Boost.IsValid() && car.Boost.IsDashing;
if ( dashing && !_wasDashing )
_fovPunchTarget = MathF.Max( _fovPunchTarget, DashFovPunch );
_wasDashing = dashing;
_fovPunchTarget = MathX.Lerp( _fovPunchTarget, 0f, Time.Delta * FovPunchDecay );
_fovPunch = MathX.Lerp( _fovPunch, _fovPunchTarget, Time.Delta * FovPunchAttack );
return BaseFieldOfView + _currentFovBoost + _fovPunch;
}
/// <summary>
/// Lerp two flat headings along the shortest arc, capped at <paramref name="maxDegrees"/> per call.
/// </summary>
private static Vector3 ClampedAngularLerp( Vector3 from, Vector3 to, float t, float maxDegrees )
{
var fromAngle = MathF.Atan2( from.y, from.x );
var toAngle = MathF.Atan2( to.y, to.x );
var delta = toAngle - fromAngle;
if ( delta > MathF.PI ) delta -= MathF.PI * 2f;
if ( delta < -MathF.PI ) delta += MathF.PI * 2f;
var step = delta * MathX.Clamp( t, 0f, 1f );
var maxRad = MathX.DegreeToRadian( maxDegrees );
step = MathX.Clamp( step, -maxRad, maxRad );
var angle = fromAngle + step;
return new Vector3( MathF.Cos( angle ), MathF.Sin( angle ), 0f );
}
/// <summary>
/// Flat track direction at a world point from the optimal line; falls back to +Y if none.
/// </summary>
private Vector3 GetTrackHeading( Vector3 sample )
{
var fallback = new Vector3( 0f, 1f, 0f );
var line = RacingPath.Current?.Optimal;
if ( line is null || !line.IsValid )
return fallback;
// Windowed projection to avoid snapping to a parallel section across the loop.
var projected = line.GetDistanceAtPosition( sample, _lastTrackDistance, 800f );
// Fall back to a full search if the windowed projection landed too far away.
var projectedPoint = line.GetPointAtDistance( projected );
var flatError = (projectedPoint - sample).WithZ( 0f ).Length;
if ( flatError > 600f )
projected = line.GetDistanceAtPosition( sample );
_lastTrackDistance = projected;
// Wide chord centred ahead, sampled on the spline to avoid faceting.
var center = _lastTrackDistance + HeadingLookAhead;
var ahead = line.GetSplinePointAtDistance( center + HeadingChord ).WithZ( 0f );
var behind = line.GetSplinePointAtDistance( center - HeadingChord ).WithZ( 0f );
var heading = ahead - behind;
return heading.LengthSquared > 0.0001f ? heading.Normal : fallback;
}
/// <summary>
/// Snap heading to nearest <see cref="HeadingSnapIncrement"/> if within <see cref="HeadingSnapThreshold"/> degrees.
/// </summary>
private Vector3 SnapHeading( Vector3 dir )
{
if ( HeadingSnapIncrement <= 0f || HeadingSnapThreshold <= 0f )
return dir;
var angle = MathX.RadianToDegree( MathF.Atan2( dir.y, dir.x ) );
var nearest = MathF.Round( angle / HeadingSnapIncrement ) * HeadingSnapIncrement;
if ( MathF.Abs( angle - nearest ) > HeadingSnapThreshold )
return dir;
var rad = MathX.DegreeToRadian( nearest );
return new Vector3( MathF.Cos( rad ), MathF.Sin( rad ), 0f );
}
/// <summary>
/// Collect positions and forward vectors of all valid spawned player cars.
/// </summary>
private List<(Vector3 Position, Vector3 Forward)> GatherPlayers()
{
var players = new List<(Vector3, Vector3)>();
foreach ( var car in Scene.GetAllComponents<Car>() )
{
if ( !car.IsValid() || car.Slot < 0 )
continue;
if ( IncludeAllPlayers || car.IsLocalPlayer )
players.Add( (car.WorldPosition, car.WorldRotation.Forward) );
}
return players;
}
}