Component that plays and controls a looping engine sound for a car. It computes a virtual-gear RPM, handles up/down shifts, rev-limiter and airborne throttle effects, and maps RPM and speed to sound pitch, volume and position each update.
namespace Machines.Player;
/// <summary>
/// Looping engine sound with virtual gears: RPM saws up per gear then drops on shift for a revving feel
/// </summary>
public sealed class CarEngineAudio : Component
{
[RequireComponent]
public Car Car { get; private set; }
/// <summary>
/// The looping engine sound event to play.
/// </summary>
[Property, Group( "Engine Sound" )]
public SoundEvent EngineSoundEvent { get; set; }
/// <summary>
/// Pitch at idle RPM.
/// </summary>
[Property, Group( "Engine Sound" ), Range( 0.3f, 1f )]
public float MinPitch { get; set; } = 0.6f;
/// <summary>
/// Pitch at redline (top of any gear).
/// </summary>
[Property, Group( "Engine Sound" ), Range( 1f, 2.5f )]
public float MaxPitch { get; set; } = 1.4f;
/// <summary>
/// Volume at idle.
/// </summary>
[Property, Group( "Engine Sound" ), Range( 0f, 1f )]
public float MinVolume { get; set; } = 0.4f;
/// <summary>
/// Volume at redline.
/// </summary>
[Property, Group( "Engine Sound" ), Range( 0f, 2f )]
public float MaxVolume { get; set; } = 1.0f;
/// <summary>
/// Speed fraction (0-1) at the top of each gear band; ascending, must end at 1.0
/// </summary>
[Property, Group( "Gears" )]
public List<float> GearThresholds { get; set; } = new() { 0.08f, 0.18f, 0.33f, 0.52f, 0.74f, 1.0f };
/// <summary>
/// RPM level (0-1) immediately after an upshift; lower = more dramatic drop
/// </summary>
[Property, Group( "Gears" ), Range( 0.2f, 0.7f )]
public float ShiftDropRatio { get; set; } = 0.35f;
/// <summary>
/// Speed fraction below the gear floor required before downshifting; prevents gear hunting
/// </summary>
[Property, Group( "Gears" ), Range( 0f, 0.3f )]
public float DownshiftHysteresis { get; set; } = 0.1f;
/// <summary>
/// How fast RPM rises toward its target per second (under throttle).
/// </summary>
[Property, Group( "RPM Dynamics" ), Range( 0.5f, 5f )]
public float RpmRiseRate { get; set; } = 2.0f;
/// <summary>
/// How fast RPM falls toward its target per second (off throttle / after shift).
/// </summary>
[Property, Group( "RPM Dynamics" ), Range( 0.5f, 5f )]
public float RpmFallRate { get; set; } = 1.5f;
/// <summary>
/// Final pitch smoothing (prevents micro-jitter). Higher = more responsive.
/// </summary>
[Property, Group( "RPM Dynamics" ), Range( 1f, 30f )]
public float PitchSmoothing { get; set; } = 10f;
/// <summary>
/// RPM (0-1) above which the rev limiter bounces RPM back for a redline effect
/// </summary>
[Property, Group( "RPM Dynamics" ), Range( 0.7f, 1f )]
public float RevLimiterThreshold { get; set; } = 0.92f;
/// <summary>
/// RPM drop when the rev limiter triggers; higher = more dramatic bounce
/// </summary>
[Property, Group( "RPM Dynamics" ), Range( 0.05f, 0.3f )]
public float RevLimiterDrop { get; set; } = 0.12f;
/// <summary>
/// Throttle's direct contribution to RPM when grounded; higher = more responsive revving
/// </summary>
[Property, Group( "Throttle" ), Range( 0f, 0.5f )]
public float ThrottleRpmContribution { get; set; } = 0.15f;
/// <summary>
/// Extra pitch when airborne under throttle (unloaded engine); scales with throttle
/// </summary>
[Property, Group( "Airborne" ), Range( 0f, 0.5f )]
public float AirbornePitchBoost { get; set; } = 0.2f;
/// <summary>
/// How fast the airborne pitch boost ramps in/out per second.
/// </summary>
[Property, Group( "Airborne" ), Range( 1f, 10f )]
public float AirbornePitchRate { get; set; } = 4f;
private SoundHandle _engineSound;
private float _currentPitch;
private float _rpm; // 0-1 within current gear
private int _currentGear; // 0-based
private bool _limiterActive;
private float _airbornePitch; // 0-1 blend for airborne boost
protected override void OnEnabled()
{
_currentPitch = MinPitch;
_rpm = 0f;
_currentGear = 0;
_airbornePitch = 0f;
}
protected override void OnUpdate()
{
if ( !Car.IsValid() || !Car.Movement.IsValid() )
return;
if ( !EngineSoundEvent.IsValid() )
return;
// Start the loop if not already playing
if ( !_engineSound.IsValid() || !_engineSound.IsPlaying )
{
_engineSound = Sound.Play( EngineSoundEvent, WorldPosition );
}
float dt = Time.Delta;
float maxSpeed = Car.ActiveStats.MaxSpeed;
float absSpeed = MathF.Abs( Car.Movement.CurrentSpeed );
float speedRatio = maxSpeed > 0f ? MathX.Clamp( absSpeed / maxSpeed, 0f, 1f ) : 0f;
float throttle = MathF.Max( 0f, Car.Movement.ThrottleInput );
bool isAirborne = !Car.Movement.IsGrounded;
int gearCount = GearThresholds.Count;
int targetGear = gearCount - 1;
for ( int i = 0; i < gearCount; i++ )
{
if ( speedRatio <= GearThresholds[i] )
{
targetGear = i;
break;
}
}
// Downshift hysteresis: drop one gear at a time; multi-gear drops skip straight there
if ( targetGear < _currentGear )
{
float currentGearFloor = _currentGear > 0 ? GearThresholds[_currentGear - 1] : 0f;
float bandSize = GearThresholds[_currentGear] - currentGearFloor;
if ( _currentGear - targetGear == 1 )
{
// Single-gear drop: apply hysteresis to prevent hunting
if ( speedRatio > currentGearFloor - DownshiftHysteresis * bandSize )
targetGear = _currentGear;
}
// Multi-gear drop (sudden stop/crash): skip straight there
}
// Handle gear changes
if ( targetGear > _currentGear )
{
// Upshift - drop RPM
_currentGear = targetGear;
_rpm = ShiftDropRatio;
}
else if ( targetGear < _currentGear )
{
if ( _currentGear - targetGear == 1 )
{
// Single downshift - rev blip
_currentGear = targetGear;
_rpm = MathX.Clamp( _rpm + 0.3f, 0f, 1f );
}
else
{
// Big drop (stopped/crashed) - snap to correct gear, reset RPM to idle
_currentGear = targetGear;
_rpm = 0f;
}
}
// Rpm target
float floor = _currentGear > 0 ? GearThresholds[_currentGear - 1] : 0f;
float ceiling = GearThresholds[_currentGear];
float gearBandSize = ceiling - floor;
float posInGear = gearBandSize > 0f
? MathX.Clamp( (speedRatio - floor) / gearBandSize, 0f, 1f )
: 0f;
// Throttle contributes extra RPM; airborne = unloaded, so contribution is much higher
float throttleContrib = isAirborne
? throttle * 0.6f
: throttle * ThrottleRpmContribution;
float targetRpm = MathX.Clamp( posInGear + throttleContrib, 0f, 1f );
// Rpm intertia
float rate = targetRpm > _rpm ? RpmRiseRate : RpmFallRate;
// Rpm when airborne
if ( isAirborne && targetRpm > _rpm )
rate *= 1.5f;
_rpm = MathX.Approach( _rpm, targetRpm, rate * dt );
// Rev limiter
if ( _rpm >= RevLimiterThreshold && throttle > 0.1f )
{
if ( !_limiterActive )
{
_limiterActive = true;
_rpm = RevLimiterThreshold - RevLimiterDrop;
}
}
else
{
_limiterActive = false;
}
// Airborne pitch
float airbornePitchTarget = (isAirborne && throttle > 0.1f) ? throttle : 0f;
_airbornePitch = MathX.Approach( _airbornePitch, airbornePitchTarget, AirbornePitchRate * dt );
// Map rpm to pitch / vol
// Higher gears get a slight pitch bump so each gear sounds distinct
float gearPitchBias = _currentGear * 0.04f;
float rpmForPitch = MathX.Clamp( _rpm + gearPitchBias, 0f, 1f );
float targetPitch = MathX.Lerp( MinPitch, MaxPitch, rpmForPitch );
targetPitch += _airbornePitch * AirbornePitchBoost;
_currentPitch = MathX.Lerp( _currentPitch, targetPitch, PitchSmoothing * dt );
// Volume blends RPM and speed so high-speed driving is always loud
float volumeRpm = MathX.Lerp( MinVolume, MaxVolume, _rpm );
float volumeSpeed = MathX.Lerp( MinVolume, MaxVolume, speedRatio * 0.5f );
float volume = MathF.Max( volumeRpm, volumeSpeed );
_engineSound.Pitch = _currentPitch;
_engineSound.Volume = volume;
_engineSound.Position = WorldPosition;
}
protected override void OnDisabled()
{
_engineSound?.Stop();
}
protected override void OnDestroy()
{
_engineSound?.Stop();
}
}