Code/FirstPersonHeadBob.cs

A first-person head bob component for player camera motion. It computes gait, idle breathing, landing spring and velocity-based offsets then applies position and rotation offsets to the scene camera each frame.

Native Interop
using System;

namespace Sandbox;

[Title( "Head Bobbing Effect" )]
[Category( "Camera" )]
[Description( "Polished first-person camera motion driven by player velocity." )]
[Icon( "visibility" )]
public sealed class FirstPersonHeadBob : Component
{
	const float MeanAbsoluteCosine = 0.6366198f;

	[Property, Group( "Motion" ), Range( 0f, 2f )]
	public float Amount { get; set; } = 1f;

	[Property, Group( "Motion" ), Range( 0f, 3f )]
	public float HorizontalAmount { get; set; } = 0.85f;

	[Property, Group( "Motion" ), Range( 0f, 3f )]
	public float VerticalAmount { get; set; } = 1.2f;

	[Property, Group( "Motion" ), Range( 0.5f, 4f )]
	public float StepFrequency { get; set; } = 1.9f;

	[Property, Group( "Motion" ), Range( 0f, 0.5f )]
	public float GaitDetail { get; set; } = 0.12f;

	[Property, Group( "Motion" ), Range( 0.25f, 1f )]
	public float CrouchMultiplier { get; set; } = 0.72f;

	[Property, Group( "Idle" ), Range( 0f, 1f )]
	public float IdleAmount { get; set; } = 0.5f;

	[Property, Group( "Idle" ), Range( 0.05f, 1f )]
	public float BreathingFrequency { get; set; } = 0.23f;

	[Property, Group( "Idle" ), Range( 0f, 1f )]
	public float IdlePitchAmount { get; set; } = 0.32f;

	[Property, Group( "Rotation" ), Range( 0f, 2f )]
	public float PitchAmount { get; set; } = 0.38f;

	[Property, Group( "Rotation" ), Range( 0f, 2f )]
	public float RollAmount { get; set; } = 0.62f;

	[Property, Group( "Velocity" ), Range( 0f, 2f )]
	public float VelocityPitchAmount { get; set; } = 0.32f;

	[Property, Group( "Velocity" ), Range( 0f, 3f )]
	public float VelocityRollAmount { get; set; } = 0.75f;

	[Property, Group( "Velocity" ), Range( 0f, 3f )]
	public float InertiaAmount { get; set; } = 0.85f;

	[Property, Group( "Velocity" ), Range( 1f, 30f )]
	public float VelocitySmoothing { get; set; } = 6.5f;

	[Property, Group( "Velocity" ), Range( 1f, 30f )]
	public float InertiaSmoothing { get; set; } = 3.5f;

	[Property, Group( "Response" ), Range( 1f, 30f )]
	public float BlendSpeed { get; set; } = 6.5f;

	[Property, Group( "Response" ), Range( 1f, 30f )]
	public float CadenceSmoothing { get; set; } = 5.5f;

	[Property, Group( "Response" ), Range( 1f, 30f )]
	public float AirborneBlendSpeed { get; set; } = 5f;

	[Property, Group( "Landing" ), Range( 0f, 4f )]
	public float LandingDip { get; set; } = 1.6f;

	[Property, Group( "Landing" ), Range( 1f, 30f )]
	public float LandingSpring { get; set; } = 15f;

	[Property, Group( "Landing" ), Range( 0.1f, 2f )]
	public float LandingDamping { get; set; } = 0.82f;

	[Property, Group( "Speed" ), Range( 0f, 100f )]
	public float MinimumSpeed { get; set; } = 1.5f;

	[Property, Group( "Speed" ), Range( 1f, 500f )]
	public float ReferenceSpeed { get; set; } = 110f;

	[Property, Group( "Speed" ), Range( 1f, 3f )]
	public float MaximumSpeedScale { get; set; } = 1.65f;

	[Property, Group( "Safety" ), Range( 0.5f, 8f )]
	public float MaximumPositionOffset { get; set; } = 3.5f;

	[Property, Group( "Safety" ), Range( 0.5f, 5f )]
	public float MaximumRotationOffset { get; set; } = 2.5f;

	PlayerController Controller { get; set; }

	float _gaitPhase;
	float _idlePhase;
	float _movementStrength;
	float _cadence;
	float _landingOffset;
	float _landingVelocity;
	float _lastVerticalVelocity;
	Vector3 _velocity;
	Vector3 _smoothedVelocity;
	Vector3 _inertiaVelocity;
	bool _wasGrounded;
	bool _isGrounded;
	bool _initialized;

	protected override void OnStart()
	{
		Controller = Components.Get<PlayerController>();
		InitializeState();
	}

	protected override void OnDisabled()
	{
		_initialized = false;
		_movementStrength = 0f;
		_cadence = 0f;
		_landingOffset = 0f;
		_landingVelocity = 0f;
	}

	protected override void OnUpdate()
	{
		Controller ??= Components.Get<PlayerController>();

		if ( !Controller.IsValid() || IsProxy || Controller.ThirdPerson )
		{
			_initialized = false;
			return;
		}

		if ( !_initialized )
			InitializeState();

		var delta = MathF.Min( Time.Delta, 0.05f );
		if ( delta <= 0f )
			return;

		_velocity = Controller.Velocity;
		var planarVelocity = new Vector3( _velocity.x, _velocity.y, 0f );
		var planarSpeed = planarVelocity.Length;
		var grounded = Controller.IsOnGround;
		var moving = grounded && planarSpeed >= MinimumSpeed;
		var referenceSpeed = MathF.Max( ReferenceSpeed, 1f );
		var speedScale = (planarSpeed / referenceSpeed).Clamp( 0f, MaximumSpeedScale );
		var crouchScale = Controller.IsDucking ? CrouchMultiplier : 1f;
		var targetStrength = moving ? speedScale * crouchScale : 0f;
		var targetCadence = moving ? speedScale * MathF.Sqrt( crouchScale ) : 0f;
		var movementBlendSpeed = grounded ? BlendSpeed : AirborneBlendSpeed;

		_movementStrength = MathX.LerpTo(
			_movementStrength,
			targetStrength,
			ExponentialBlend( movementBlendSpeed, delta )
		);
		_cadence = MathX.LerpTo(
			_cadence,
			targetCadence,
			ExponentialBlend( CadenceSmoothing, delta )
		);
		_smoothedVelocity = Vector3.Lerp(
			_smoothedVelocity,
			planarVelocity,
			ExponentialBlend( VelocitySmoothing, delta )
		);
		_inertiaVelocity = Vector3.Lerp(
			_inertiaVelocity,
			_smoothedVelocity,
			ExponentialBlend( InertiaSmoothing, delta )
		);

		_idlePhase = (_idlePhase + MathF.Tau * BreathingFrequency * delta) % (MathF.Tau * 2f);

		if ( grounded && _cadence > 0.001f )
		{
			_gaitPhase += MathF.PI * StepFrequency * _cadence * delta;
			_gaitPhase %= MathF.Tau * 2f;
		}

		if ( grounded && !_wasGrounded )
		{
			var impact = (-_lastVerticalVelocity / 600f).Clamp( 0f, 1f );
			_landingVelocity -= impact * LandingDip * LandingSpring;
		}

		UpdateLandingSpring( delta );

		if ( !grounded )
			_lastVerticalVelocity = _velocity.z;

		_wasGrounded = grounded;
		_isGrounded = grounded;
	}

	protected override void OnPreRender()
	{
		if ( !_initialized || !Controller.IsValid() || IsProxy || Controller.ThirdPerson )
			return;

		var camera = Scene.Camera;
		if ( !camera.IsValid() )
			return;

		var rotation = camera.WorldRotation;
		var flatForward = new Vector3( rotation.Forward.x, rotation.Forward.y, 0f );
		var flatLength = flatForward.Length;
		flatForward = flatLength > 0.001f ? flatForward / flatLength : Vector3.Forward;
		var flatRight = new Vector3( -flatForward.y, flatForward.x, 0f );
		var referenceSpeed = MathF.Max( ReferenceSpeed, 1f );
		var forwardSpeed = (_smoothedVelocity.Dot( flatForward ) / referenceSpeed)
			.Clamp( -MaximumSpeedScale, MaximumSpeedScale );
		var sideSpeed = (_smoothedVelocity.Dot( flatRight ) / referenceSpeed)
			.Clamp( -MaximumSpeedScale, MaximumSpeedScale );
		var velocityLag = (_smoothedVelocity - _inertiaVelocity) / referenceSpeed;
		var lagForward = velocityLag.Dot( flatForward ).Clamp( -1f, 1f );
		var lagSide = velocityLag.Dot( flatRight ).Clamp( -1f, 1f );

		var sideWave = MathF.Sin( _gaitPhase * 0.5f );
		var sideDetail = MathF.Sin( _gaitPhase * 1.5f + 0.35f ) * GaitDetail;
		var footPlant = MathF.Abs( MathF.Cos( _gaitPhase ) ) - MeanAbsoluteCosine;
		var stepDetail = MathF.Sin( _gaitPhase * 2f + 0.7f ) * GaitDetail;
		var movementAmount = Amount * _movementStrength;
		var idleBlend = _isGrounded
			? 1f - (_movementStrength / 0.45f).Clamp( 0f, 1f )
			: 0f;
		var breathWave = MathF.Sin( _idlePhase );
		var weightShift = MathF.Sin( _idlePhase * 0.5f );

		var sideOffset = (sideWave + sideDetail) * HorizontalAmount * movementAmount;
		sideOffset += weightShift * IdleAmount * 0.22f * idleBlend * Amount;
		sideOffset -= lagSide * InertiaAmount * Amount;

		var verticalOffset = -(footPlant + stepDetail) * VerticalAmount * movementAmount;
		verticalOffset += breathWave * IdleAmount * idleBlend * Amount;
		verticalOffset += _landingOffset * Amount;

		var forwardOffset = -lagForward * InertiaAmount * Amount;
		var pitch = (footPlant + stepDetail * 0.5f) * PitchAmount * movementAmount;
		pitch -= breathWave * IdlePitchAmount * idleBlend * Amount;
		pitch -= forwardSpeed * VelocityPitchAmount * Amount;
		pitch -= lagForward * VelocityPitchAmount * Amount;

		var roll = -(sideWave + sideDetail * 0.5f) * RollAmount * movementAmount;
		roll += weightShift * IdlePitchAmount * 0.22f * idleBlend * Amount;
		roll += sideSpeed * VelocityRollAmount * Amount;
		roll += lagSide * VelocityRollAmount * Amount;

		var localOffset = new Vector3( forwardOffset, sideOffset, verticalOffset );
		var offsetLength = localOffset.Length;
		if ( offsetLength > MaximumPositionOffset )
			localOffset *= MaximumPositionOffset / offsetLength;

		pitch = pitch.Clamp( -MaximumRotationOffset, MaximumRotationOffset );
		roll = roll.Clamp( -MaximumRotationOffset, MaximumRotationOffset );

		camera.WorldPosition += rotation.Forward * localOffset.x;
		camera.WorldPosition += rotation.Right * localOffset.y;
		camera.WorldPosition += rotation.Up * localOffset.z;
		camera.WorldRotation *= Rotation.From( pitch, 0f, roll );
	}

	void InitializeState()
	{
		if ( !Controller.IsValid() )
			return;

		_gaitPhase = 0f;
		_movementStrength = 0f;
		_cadence = 0f;
		_velocity = Controller.Velocity;
		var planarVelocity = new Vector3( _velocity.x, _velocity.y, 0f );
		_smoothedVelocity = planarVelocity;
		_inertiaVelocity = planarVelocity;
		_wasGrounded = Controller.IsOnGround;
		_isGrounded = _wasGrounded;
		_lastVerticalVelocity = _velocity.z;
		_landingOffset = 0f;
		_landingVelocity = 0f;
		_initialized = true;
	}

	void UpdateLandingSpring( float delta )
	{
		var remaining = delta;
		var spring = MathF.Max( LandingSpring, 1f );
		var damping = MathF.Max( LandingDamping, 0.1f );

		while ( remaining > 0f )
		{
			var step = MathF.Min( remaining, 1f / 120f );
			var acceleration = -spring * spring * _landingOffset
				- 2f * damping * spring * _landingVelocity;
			_landingVelocity += acceleration * step;
			_landingOffset += _landingVelocity * step;
			remaining -= step;
		}

		_landingOffset = _landingOffset.Clamp( -LandingDip * 1.25f, LandingDip * 0.35f );
	}

	static float ExponentialBlend( float speed, float delta )
	{
		return 1f - MathF.Exp( -MathF.Max( speed, 0.01f ) * delta );
	}
}